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,406 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * WidgetRegistry
5
+ *
6
+ * Maps field types to widget descriptors that control how a field is
7
+ * rendered in the admin form and how its raw submitted value is coerced.
8
+ *
9
+ * ── What a widget is ─────────────────────────────────────────────────────────
10
+ *
11
+ * A widget is a plain object with three responsibilities:
12
+ *
13
+ * type {string} — the HTML input type sent to the template
14
+ * (text | email | number | checkbox | select | textarea
15
+ * | password | date | datetime-local | color | url |
16
+ * phone | json | richtext | image | hidden)
17
+ *
18
+ * coerce {Function} — (rawValue, fieldDef) => typedValue
19
+ * converts the raw string from req.body into the
20
+ * correct JS type before the ORM sees it.
21
+ * This is the single source of truth for type coercion
22
+ * (used by _sanitise() in AdminResource).
23
+ *
24
+ * validate {Function} — (value, fieldDef) => string | null
25
+ * returns an error message string, or null if valid.
26
+ * Called by FormValidator before the ORM write.
27
+ *
28
+ * ── Built-in type → widget mapping ───────────────────────────────────────────
29
+ *
30
+ * ORM type Widget type Notes
31
+ * ────────── ─────────── ─────────────────────────────────────────────
32
+ * id hidden never shown in forms
33
+ * string text max length validated from fieldDef.max
34
+ * text textarea 4-row textarea
35
+ * integer number parseInt, step=1
36
+ * bigInteger number parseInt, step=1
37
+ * float number parseFloat
38
+ * decimal number parseFloat, step derived from fieldDef.scale
39
+ * boolean checkbox 'on'/missing → true/false
40
+ * enum select options from fieldDef.enumValues
41
+ * timestamp datetime-local datetime-local input
42
+ * date date date input
43
+ * json json textarea with JSON validation
44
+ * uuid text plain text
45
+ *
46
+ * ── Custom widgets ────────────────────────────────────────────────────────────
47
+ *
48
+ * Register a custom widget globally:
49
+ *
50
+ * const { WidgetRegistry } = require('millas/src/admin');
51
+ *
52
+ * WidgetRegistry.register('color', {
53
+ * type: 'color',
54
+ * coerce: (raw) => raw || null,
55
+ * validate: (val, def) => null,
56
+ * });
57
+ *
58
+ * Override per-field in AdminConfig:
59
+ *
60
+ * class PostAdmin extends AdminResource {
61
+ * static widgets = {
62
+ * body: { type: 'richtext' },
63
+ * cover: { type: 'image' },
64
+ * status: { type: 'select' },
65
+ * };
66
+ * }
67
+ *
68
+ * ── Using the registry ────────────────────────────────────────────────────────
69
+ *
70
+ * const widget = WidgetRegistry.resolve('integer', fieldDef);
71
+ * const typed = widget.coerce('42', fieldDef); // → 42
72
+ * const err = widget.validate(typed, fieldDef); // → null
73
+ */
74
+ class WidgetRegistry {
75
+ constructor() {
76
+ /** @type {Map<string, object>} ORM type → widget descriptor */
77
+ this._widgets = new Map();
78
+ this._registerDefaults();
79
+ }
80
+
81
+ // ─── Registration ──────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Register or override a widget for an ORM field type.
85
+ *
86
+ * @param {string} ormType — the field.type value (e.g. 'string', 'integer')
87
+ * @param {object} widget — { type, coerce, validate }
88
+ */
89
+ register(ormType, widget) {
90
+ if (!widget.type) throw new Error(`[WidgetRegistry] widget for "${ormType}" must have a type`);
91
+ if (!widget.coerce) widget.coerce = (v) => v;
92
+ if (!widget.validate) widget.validate = () => null;
93
+ this._widgets.set(ormType, widget);
94
+ return this;
95
+ }
96
+
97
+ /**
98
+ * Resolve the widget for a given ORM type.
99
+ * Falls back to a plain text widget if the type is unknown.
100
+ *
101
+ * @param {string} ormType
102
+ * @param {object} [fieldDef] — field definition (used by some built-ins)
103
+ * @returns {object} widget descriptor
104
+ */
105
+ resolve(ormType, fieldDef = {}) {
106
+ return this._widgets.get(ormType) || this._widgets.get('string');
107
+ }
108
+
109
+ /**
110
+ * Check if a custom widget is registered for an ORM type.
111
+ */
112
+ has(ormType) {
113
+ return this._widgets.has(ormType);
114
+ }
115
+
116
+ /**
117
+ * Return all registered ORM types.
118
+ */
119
+ types() {
120
+ return [...this._widgets.keys()];
121
+ }
122
+
123
+ // ─── Default widgets ───────────────────────────────────────────────────────
124
+
125
+ _registerDefaults() {
126
+
127
+ // ── id ──────────────────────────────────────────────────────────────────
128
+ this.register('id', {
129
+ type: 'hidden',
130
+ coerce: () => undefined, // id is never written
131
+ validate: () => null,
132
+ });
133
+
134
+ // ── string ──────────────────────────────────────────────────────────────
135
+ this.register('string', {
136
+ type: 'text',
137
+ coerce(raw, def) {
138
+ if (raw === undefined || raw === null) return def?.nullable ? null : '';
139
+ const s = String(raw);
140
+ return s === '' && def?.nullable ? null : s;
141
+ },
142
+ validate(val, def) {
143
+ if ((val === null || val === '' || val === undefined) && !def?.nullable) {
144
+ return 'This field is required.';
145
+ }
146
+ if (def?.max && val && String(val).length > def.max) {
147
+ return `Maximum ${def.max} characters allowed.`;
148
+ }
149
+ return null;
150
+ },
151
+ });
152
+
153
+ // ── text ─────────────────────────────────────────────────────────────────
154
+ this.register('text', {
155
+ type: 'textarea',
156
+ coerce(raw, def) {
157
+ if (raw === undefined || raw === null) return def?.nullable ? null : '';
158
+ const s = String(raw);
159
+ return s === '' && def?.nullable ? null : s;
160
+ },
161
+ validate(val, def) {
162
+ if ((val === null || val === '' || val === undefined) && !def?.nullable) {
163
+ return 'This field is required.';
164
+ }
165
+ return null;
166
+ },
167
+ });
168
+
169
+ // ── integer ──────────────────────────────────────────────────────────────
170
+ this.register('integer', {
171
+ type: 'number',
172
+ coerce(raw, def) {
173
+ if (raw === '' || raw === null || raw === undefined) {
174
+ return def?.nullable ? null : 0;
175
+ }
176
+ const n = parseInt(raw, 10);
177
+ return isNaN(n) ? (def?.nullable ? null : 0) : n;
178
+ },
179
+ validate(val, def) {
180
+ if ((val === null || val === undefined) && !def?.nullable) {
181
+ return 'This field is required.';
182
+ }
183
+ if (val !== null && val !== undefined && !Number.isInteger(Number(val))) {
184
+ return 'Must be a whole number.';
185
+ }
186
+ return null;
187
+ },
188
+ });
189
+
190
+ // ── bigInteger ───────────────────────────────────────────────────────────
191
+ this.register('bigInteger', {
192
+ type: 'number',
193
+ coerce(raw, def) {
194
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : 0;
195
+ const n = parseInt(raw, 10);
196
+ return isNaN(n) ? (def?.nullable ? null : 0) : n;
197
+ },
198
+ validate: this._widgets.get?.('integer')?.validate || (() => null),
199
+ });
200
+
201
+ // ── float ────────────────────────────────────────────────────────────────
202
+ this.register('float', {
203
+ type: 'number',
204
+ coerce(raw, def) {
205
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : 0;
206
+ const n = parseFloat(raw);
207
+ return isNaN(n) ? (def?.nullable ? null : 0) : n;
208
+ },
209
+ validate(val, def) {
210
+ if ((val === null || val === undefined) && !def?.nullable) return 'This field is required.';
211
+ if (val !== null && val !== undefined && isNaN(Number(val))) return 'Must be a number.';
212
+ return null;
213
+ },
214
+ });
215
+
216
+ // ── decimal ──────────────────────────────────────────────────────────────
217
+ this.register('decimal', {
218
+ type: 'number',
219
+ coerce(raw, def) {
220
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : 0;
221
+ const n = parseFloat(raw);
222
+ return isNaN(n) ? (def?.nullable ? null : 0) : n;
223
+ },
224
+ validate(val, def) {
225
+ if ((val === null || val === undefined) && !def?.nullable) return 'This field is required.';
226
+ if (val !== null && val !== undefined && isNaN(Number(val))) return 'Must be a number.';
227
+ return null;
228
+ },
229
+ });
230
+
231
+ // ── boolean ──────────────────────────────────────────────────────────────
232
+ this.register('boolean', {
233
+ type: 'checkbox',
234
+ coerce(raw) {
235
+ if (raw === undefined || raw === null || raw === '' || raw === false || raw === '0' || raw === 'false') return false;
236
+ if (raw === true || raw === 'on' || raw === '1' || raw === 'true') return true;
237
+ return Boolean(raw);
238
+ },
239
+ validate: () => null, // booleans are always valid (true or false)
240
+ });
241
+
242
+ // ── enum ─────────────────────────────────────────────────────────────────
243
+ this.register('enum', {
244
+ type: 'select',
245
+ coerce(raw, def) {
246
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : (def?.enumValues?.[0] ?? null);
247
+ return raw;
248
+ },
249
+ validate(val, def) {
250
+ if ((val === null || val === undefined) && !def?.nullable) return 'Please select a value.';
251
+ if (val && def?.enumValues && !def.enumValues.includes(val)) {
252
+ return `Invalid value. Must be one of: ${def.enumValues.join(', ')}`;
253
+ }
254
+ return null;
255
+ },
256
+ });
257
+
258
+ // ── timestamp ────────────────────────────────────────────────────────────
259
+ this.register('timestamp', {
260
+ type: 'datetime-local',
261
+ coerce(raw, def) {
262
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : undefined;
263
+ // datetime-local gives 'YYYY-MM-DDTHH:mm' — convert to ISO string
264
+ try {
265
+ const d = new Date(raw);
266
+ return isNaN(d.getTime()) ? (def?.nullable ? null : undefined) : d.toISOString();
267
+ } catch {
268
+ return def?.nullable ? null : undefined;
269
+ }
270
+ },
271
+ validate(val, def) {
272
+ if ((val === null || val === undefined) && !def?.nullable) return 'This field is required.';
273
+ return null;
274
+ },
275
+ });
276
+
277
+ // ── date ─────────────────────────────────────────────────────────────────
278
+ this.register('date', {
279
+ type: 'date',
280
+ coerce(raw, def) {
281
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : undefined;
282
+ return raw; // 'YYYY-MM-DD' — pass through as-is, DB handles it
283
+ },
284
+ validate(val, def) {
285
+ if ((val === null || val === undefined) && !def?.nullable) return 'This field is required.';
286
+ if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) return 'Must be a valid date (YYYY-MM-DD).';
287
+ return null;
288
+ },
289
+ });
290
+
291
+ // ── json ─────────────────────────────────────────────────────────────────
292
+ this.register('json', {
293
+ type: 'json',
294
+ coerce(raw, def) {
295
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : {};
296
+ if (typeof raw === 'object') return raw;
297
+ try { return JSON.parse(raw); } catch { return raw; } // leave as-is, validate will catch
298
+ },
299
+ validate(val, def) {
300
+ if ((val === null || val === undefined) && !def?.nullable) return 'This field is required.';
301
+ if (typeof val === 'string') {
302
+ try { JSON.parse(val); } catch { return 'Must be valid JSON.'; }
303
+ }
304
+ return null;
305
+ },
306
+ });
307
+
308
+ // ── uuid ─────────────────────────────────────────────────────────────────
309
+ this.register('uuid', {
310
+ type: 'text',
311
+ coerce(raw, def) {
312
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : '';
313
+ return String(raw).trim();
314
+ },
315
+ validate(val, def) {
316
+ if ((val === null || val === '' || val === undefined) && !def?.nullable) return 'This field is required.';
317
+ if (val && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val)) {
318
+ return 'Must be a valid UUID.';
319
+ }
320
+ return null;
321
+ },
322
+ });
323
+
324
+ // ── email (AdminField type, not ORM type — registered for completeness) ──
325
+ this.register('email', {
326
+ type: 'email',
327
+ coerce(raw, def) {
328
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : '';
329
+ return String(raw).trim().toLowerCase();
330
+ },
331
+ validate(val, def) {
332
+ if ((val === null || val === '' || val === undefined) && !def?.nullable) return 'This field is required.';
333
+ if (val && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return 'Must be a valid email address.';
334
+ return null;
335
+ },
336
+ });
337
+
338
+ // ── url ───────────────────────────────────────────────────────────────────
339
+ this.register('url', {
340
+ type: 'url',
341
+ coerce(raw, def) {
342
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : '';
343
+ return String(raw).trim();
344
+ },
345
+ validate(val, def) {
346
+ if ((val === null || val === '' || val === undefined) && !def?.nullable) return 'This field is required.';
347
+ if (val) {
348
+ try { new URL(val); } catch { return 'Must be a valid URL.'; }
349
+ }
350
+ return null;
351
+ },
352
+ });
353
+
354
+ // ── password ──────────────────────────────────────────────────────────────
355
+ this.register('password', {
356
+ type: 'password',
357
+ coerce(raw) {
358
+ // Empty password on edit = keep existing (return undefined so ORM skips it)
359
+ if (raw === '' || raw === null || raw === undefined) return undefined;
360
+ return String(raw);
361
+ },
362
+ validate(val, def) {
363
+ // On edit, empty is allowed (means "don't change password")
364
+ if (val === undefined || val === null || val === '') return null;
365
+ if (val.length < 8) return 'Password must be at least 8 characters.';
366
+ return null;
367
+ },
368
+ });
369
+ // ── fk (ForeignKey) ──────────────────────────────────────────────────────
370
+ // Rendered as a searchable select backed by the /admin/api/:resource/options
371
+ // autocomplete endpoint. Falls back to a plain number input if no related
372
+ // resource is registered.
373
+ this.register('fk', {
374
+ type: 'fk',
375
+ coerce(raw, def) {
376
+ if (raw === '' || raw === null || raw === undefined) return def?.nullable ? null : undefined;
377
+ const n = parseInt(raw, 10);
378
+ return isNaN(n) ? (def?.nullable ? null : undefined) : n;
379
+ },
380
+ validate(val, def) {
381
+ if ((val === null || val === undefined) && !def?.nullable) {
382
+ return 'Please select a related record.';
383
+ }
384
+ return null;
385
+ },
386
+ });
387
+
388
+ // ── m2m (ManyToMany) ──────────────────────────────────────────────────
389
+ // Rendered as a dual-list widget (available → chosen).
390
+ // Values submitted as multiple values under the same field name.
391
+ this.register('m2m', {
392
+ type: 'm2m',
393
+ coerce(raw) {
394
+ if (!raw) return [];
395
+ if (Array.isArray(raw)) return raw.map(v => parseInt(v, 10)).filter(n => !isNaN(n));
396
+ return [parseInt(raw, 10)].filter(n => !isNaN(n));
397
+ },
398
+ validate: () => null,
399
+ });
400
+ }
401
+ }
402
+
403
+ // ── Singleton global registry ─────────────────────────────────────────────────
404
+ const widgetRegistry = new WidgetRegistry();
405
+
406
+ module.exports = { WidgetRegistry, widgetRegistry };
@@ -5,6 +5,11 @@ const AdminAuth = require('./AdminAuth');
5
5
  const ActivityLog = require('./ActivityLog');
6
6
  const AdminServiceProvider = require('../providers/AdminServiceProvider');
7
7
  const { AdminResource, AdminField, AdminFilter, AdminInline } = require('./resources/AdminResource');
8
+ const { AdminHooks, HookRegistry, HookPipeline, VALID_EVENTS } = require('./HookRegistry');
9
+ const { FormGenerator } = require('./FormGenerator');
10
+ const { WidgetRegistry, widgetRegistry } = require('./WidgetRegistry');
11
+ const { QueryEngine } = require('./QueryEngine');
12
+ const { ViewContext } = require('./ViewContext');
8
13
 
9
14
  module.exports = {
10
15
  Admin,
@@ -15,4 +20,16 @@ module.exports = {
15
20
  AdminFilter,
16
21
  AdminInline,
17
22
  AdminServiceProvider,
23
+ // Hook system
24
+ AdminHooks,
25
+ HookRegistry,
26
+ HookPipeline,
27
+ VALID_EVENTS,
28
+ // Form system
29
+ FormGenerator,
30
+ WidgetRegistry,
31
+ widgetRegistry,
32
+ // Query + View
33
+ QueryEngine,
34
+ ViewContext,
18
35
  };