millas 0.2.12-beta-1 → 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 (82) hide show
  1. package/package.json +1 -1
  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 +18 -2
  31. package/src/auth/AuthUser.js +65 -44
  32. package/src/cli.js +3 -1
  33. package/src/commands/createsuperuser.js +254 -0
  34. package/src/commands/lang.js +589 -0
  35. package/src/commands/migrate.js +154 -81
  36. package/src/commands/serve.js +1 -0
  37. package/src/container/AppInitializer.js +65 -8
  38. package/src/container/MillasApp.js +10 -3
  39. package/src/container/MillasConfig.js +35 -6
  40. package/src/core/admin.js +5 -0
  41. package/src/core/db.js +2 -1
  42. package/src/core/foundation.js +1 -9
  43. package/src/core/lang.js +1 -0
  44. package/src/i18n/I18nServiceProvider.js +91 -0
  45. package/src/i18n/Translator.js +635 -0
  46. package/src/i18n/defaults.js +122 -0
  47. package/src/i18n/index.js +164 -0
  48. package/src/i18n/locales/en.js +55 -0
  49. package/src/i18n/locales/sw.js +48 -0
  50. package/src/logger/formatters/PrettyFormatter.js +101 -65
  51. package/src/migrations/system/0001_users.js +21 -0
  52. package/src/migrations/system/0002_admin_log.js +25 -0
  53. package/src/migrations/system/0003_sessions.js +23 -0
  54. package/src/orm/fields/index.js +210 -188
  55. package/src/orm/migration/DefaultValueParser.js +325 -0
  56. package/src/orm/migration/InteractiveResolver.js +191 -0
  57. package/src/orm/migration/Makemigrations.js +312 -0
  58. package/src/orm/migration/MigrationGraph.js +227 -0
  59. package/src/orm/migration/MigrationRunner.js +202 -108
  60. package/src/orm/migration/MigrationWriter.js +463 -0
  61. package/src/orm/migration/ModelInspector.js +143 -74
  62. package/src/orm/migration/ModelScanner.js +225 -0
  63. package/src/orm/migration/ProjectState.js +213 -0
  64. package/src/orm/migration/RenameDetector.js +175 -0
  65. package/src/orm/migration/SchemaBuilder.js +8 -81
  66. package/src/orm/migration/operations/base.js +57 -0
  67. package/src/orm/migration/operations/column.js +191 -0
  68. package/src/orm/migration/operations/fields.js +252 -0
  69. package/src/orm/migration/operations/index.js +55 -0
  70. package/src/orm/migration/operations/models.js +152 -0
  71. package/src/orm/migration/operations/registry.js +131 -0
  72. package/src/orm/migration/operations/special.js +51 -0
  73. package/src/orm/migration/utils.js +208 -0
  74. package/src/orm/model/Model.js +81 -13
  75. package/src/providers/AdminServiceProvider.js +66 -9
  76. package/src/providers/AuthServiceProvider.js +40 -5
  77. package/src/providers/CacheStorageServiceProvider.js +2 -2
  78. package/src/providers/DatabaseServiceProvider.js +3 -2
  79. package/src/providers/LogServiceProvider.js +4 -1
  80. package/src/providers/MailServiceProvider.js +1 -1
  81. package/src/providers/QueueServiceProvider.js +1 -1
  82. package/src/scaffold/templates.js +77 -21
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Millas framework default translatable strings.
5
+ *
6
+ * These are the strings used internally by Millas — validation messages,
7
+ * auth errors, admin panel labels, migration prompts, etc.
8
+ *
9
+ * When a developer runs `millas lang:publish sw --defaults`, these keys
10
+ * are merged into their locale file alongside their own app strings.
11
+ *
12
+ * Organised by module so translators know the context.
13
+ * All values are the English source text — same key = same value for 'en'.
14
+ *
15
+ * Format:
16
+ * 'key': 'English source text' — simple string
17
+ * 'key': ['singular', 'plural'] — plural form
18
+ * 'context|key': 'English source text' — contextual
19
+ */
20
+ module.exports = {
21
+
22
+ // ── Validation ─────────────────────────────────────────────────────────
23
+ 'This field is required.': 'This field is required.',
24
+ 'Maximum {max} characters allowed.': 'Maximum {max} characters allowed.',
25
+ 'Must be a whole number.': 'Must be a whole number.',
26
+ 'Must be a number.': 'Must be a number.',
27
+ 'Must be a valid email address.': 'Must be a valid email address.',
28
+ 'Must be a valid URL.': 'Must be a valid URL.',
29
+ 'Must be a valid date (YYYY-MM-DD).': 'Must be a valid date (YYYY-MM-DD).',
30
+ 'Must be valid JSON.': 'Must be valid JSON.',
31
+ 'Must be a valid UUID.': 'Must be a valid UUID.',
32
+ 'Please select a value.': 'Please select a value.',
33
+ 'Please select a related record.': 'Please select a related record.',
34
+ 'Invalid value. Must be one of: {values}': 'Invalid value. Must be one of: {values}',
35
+ 'Password must be at least 8 characters.': 'Password must be at least 8 characters.',
36
+
37
+ // ── Auth ───────────────────────────────────────────────────────────────
38
+ 'Email is required': 'Email is required',
39
+ 'Password is required': 'Password is required',
40
+ 'Invalid email or password': 'Invalid email or password',
41
+ 'Your account has been deactivated': 'Your account has been deactivated',
42
+ 'Email already in use': 'Email already in use',
43
+ 'Passwords do not match': 'Passwords do not match',
44
+ 'Token has expired': 'Token has expired',
45
+ 'Invalid token': 'Invalid token',
46
+ 'You are not authorized to perform this action': 'You are not authorized to perform this action',
47
+
48
+ // ── HTTP errors ────────────────────────────────────────────────────────
49
+ 'Not Found': 'Not Found',
50
+ 'Unauthorized': 'Unauthorized',
51
+ 'Forbidden': 'Forbidden',
52
+ 'Unprocessable Entity': 'Unprocessable Entity',
53
+ 'Internal Server Error': 'Internal Server Error',
54
+ 'Too Many Requests': 'Too Many Requests',
55
+ '{model} #{id} not found': '{model} #{id} not found',
56
+
57
+ // ── Pagination ─────────────────────────────────────────────────────────
58
+ 'Showing {from} to {to} of {total} results': 'Showing {from} to {to} of {total} results',
59
+ 'Previous': 'Previous',
60
+ 'Next': 'Next',
61
+ 'Page {page} of {total}': 'Page {page} of {total}',
62
+
63
+ // ── CRUD ───────────────────────────────────────────────────────────────
64
+ '{model} created successfully': '{model} created successfully',
65
+ '{model} updated successfully': '{model} updated successfully',
66
+ '{model} deleted': '{model} deleted',
67
+ 'No records found': 'No records found',
68
+ 'Record not found': 'Record not found',
69
+
70
+ // ── Plural CRUD ────────────────────────────────────────────────────────
71
+ '{count} record deleted': ['{count} record deleted', '{count} records deleted'],
72
+ '{count} record updated': ['{count} record updated', '{count} records updated'],
73
+
74
+ // ── Admin panel ────────────────────────────────────────────────────────
75
+ 'Dashboard': 'Dashboard',
76
+ 'Search': 'Search',
77
+ 'Filters': 'Filters',
78
+ 'Actions': 'Actions',
79
+ 'Export CSV': 'Export CSV',
80
+ 'Export JSON': 'Export JSON',
81
+ 'Save': 'Save',
82
+ 'Save and continue editing': 'Save and continue editing',
83
+ 'Save and add another': 'Save and add another',
84
+ 'Delete': 'Delete',
85
+ 'Cancel': 'Cancel',
86
+ 'Edit': 'Edit',
87
+ 'Add {model}': 'Add {model}',
88
+ 'Change {model}': 'Change {model}',
89
+ 'Delete {model}': 'Delete {model}',
90
+ 'Are you sure you want to delete {label}?': 'Are you sure you want to delete {label}?',
91
+ 'This action cannot be undone.': 'This action cannot be undone.',
92
+ 'No {model} yet': 'No {model} yet',
93
+ 'Select all': 'Select all',
94
+ 'Deselect all': 'Deselect all',
95
+
96
+ // ── Admin status badges (contextual) ───────────────────────────────────
97
+ 'status|active': 'Active',
98
+ 'status|inactive': 'Inactive',
99
+ 'status|pending': 'Pending',
100
+ 'status|approved': 'Approved',
101
+ 'status|rejected': 'Rejected',
102
+ 'status|draft': 'Draft',
103
+ 'status|published': 'Published',
104
+
105
+ // ── Admin login ────────────────────────────────────────────────────────
106
+ 'Sign in to your account': 'Sign in to your account',
107
+ 'Email address': 'Email address',
108
+ 'Password': 'Password',
109
+ 'Remember me': 'Remember me',
110
+ 'Sign in': 'Sign in',
111
+ 'Sign out': 'Sign out',
112
+ 'You have been logged out.': 'You have been logged out.',
113
+
114
+ // ── Migration prompts ──────────────────────────────────────────────────
115
+ 'No changes detected.': 'No changes detected.',
116
+ 'Migrations generated:': 'Migrations generated:',
117
+ 'Run: millas migrate to apply.': 'Run: millas migrate to apply.',
118
+
119
+ // ── Queue ──────────────────────────────────────────────────────────────
120
+ 'Job failed: {message}': 'Job failed: {message}',
121
+ 'Job completed: {job}': 'Job completed: {job}',
122
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const Translator = require('./Translator');
4
+
5
+ /**
6
+ * Trans — global translation singleton + shorthand helpers.
7
+ *
8
+ * This is the object you import in controllers, models, views, and commands.
9
+ * It wraps the Translator instance with the Django-style shorthand functions.
10
+ *
11
+ * ── Import styles ──────────────────────────────────────────────────────────
12
+ *
13
+ * // Named destructuring (recommended)
14
+ * const { __, _n, _p, _f } = require('millas/src/i18n');
15
+ *
16
+ * // Full facade for locale control
17
+ * const { Trans } = require('millas/src/i18n');
18
+ * Trans.setLocale('sw');
19
+ *
20
+ * ── API ────────────────────────────────────────────────────────────────────
21
+ *
22
+ * __('Hello')
23
+ * → 'Habari' (when locale=sw)
24
+ * → 'Hello' (when locale=en or no translation found)
25
+ *
26
+ * _n('You have %d message', 'You have %d messages', 3)
27
+ * → 'Una ujumbe 3'
28
+ *
29
+ * _p('menu', 'File')
30
+ * → 'Faili' (contextual — same word, different contexts)
31
+ *
32
+ * _f('Welcome, {name}!', { name: 'Alice' })
33
+ * → 'Karibu, Alice!'
34
+ *
35
+ * _f('Hello %s, you have %d items', 'Alice', 5)
36
+ * → 'Habari Alice, una vitu 5'
37
+ *
38
+ * _fn('You have %d item', 'You have %d items', 3)
39
+ * → 'Una vitu 3'
40
+ *
41
+ * ── In templates (Nunjucks) ────────────────────────────────────────────────
42
+ *
43
+ * Add the filters via I18nServiceProvider (done automatically):
44
+ *
45
+ * {{ 'Hello' | __ }}
46
+ * {{ 'You have %d messages' | _n(count) }}
47
+ * {{ 'Welcome, {name}!' | _f({ name: user.name }) }}
48
+ *
49
+ * ── In CLI commands ────────────────────────────────────────────────────────
50
+ *
51
+ * const { __ } = require('millas/src/i18n');
52
+ * console.log(__('Migrations applied successfully.'));
53
+ */
54
+
55
+ // ── Singleton Translator instance ────────────────────────────────────────────
56
+ const Trans = new Translator();
57
+
58
+ // ── Shorthand functions ───────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * gettext — translate a string.
62
+ *
63
+ * __('Hello')
64
+ * __('Hello', 'sw') // explicit locale override
65
+ *
66
+ * @param {string} key
67
+ * @param {string} [locale]
68
+ * @returns {string}
69
+ */
70
+ function __(key, locale) {
71
+ return Trans.translate(key, locale);
72
+ }
73
+
74
+ /**
75
+ * ngettext — singular/plural translation.
76
+ *
77
+ * _n('You have %d message', 'You have %d messages', count)
78
+ *
79
+ * Note: this does NOT interpolate %d — call _fn() if you also need
80
+ * the count substituted into the string.
81
+ *
82
+ * @param {string} singular
83
+ * @param {string} plural
84
+ * @param {number} count
85
+ * @param {string} [locale]
86
+ * @returns {string}
87
+ */
88
+ function _n(singular, plural, count, locale) {
89
+ return Trans.ngettext(singular, plural, count, locale);
90
+ }
91
+
92
+ /**
93
+ * pgettext — contextual translation.
94
+ *
95
+ * _p('menu', 'File') // the menu item "File"
96
+ * _p('action', 'File') // the action "to file something"
97
+ *
98
+ * @param {string} context
99
+ * @param {string} key
100
+ * @param {string} [locale]
101
+ * @returns {string}
102
+ */
103
+ function _p(context, key, locale) {
104
+ return Trans.pgettext(context, key, locale);
105
+ }
106
+
107
+ /**
108
+ * format — translate + interpolate named or positional variables.
109
+ *
110
+ * _f('Welcome, {name}!', { name: 'Alice' })
111
+ * _f('Hello %s, you have %d items', 'Alice', 5)
112
+ *
113
+ * @param {string} key
114
+ * @param {...*} args
115
+ * @returns {string}
116
+ */
117
+ function _f(key, ...args) {
118
+ return Trans.format(key, ...args);
119
+ }
120
+
121
+ /**
122
+ * nformat — plural translation + interpolation.
123
+ *
124
+ * _fn('You have %d item', 'You have %d items', count)
125
+ * // → 'You have 3 items' (count is auto-interpolated as first %d)
126
+ *
127
+ * @param {string} singular
128
+ * @param {string} plural
129
+ * @param {number} count
130
+ * @param {...*} args — additional interpolation values after count
131
+ * @returns {string}
132
+ */
133
+ function _fn(singular, plural, count, ...args) {
134
+ return Trans.nformat(singular, plural, count, ...args);
135
+ }
136
+
137
+ /**
138
+ * lazy — returns a lazy-evaluated translation proxy.
139
+ *
140
+ * Useful when the translation needs to be stored before the locale is set
141
+ * (e.g. in class-level constants, model field labels).
142
+ *
143
+ * const label = lazy__('Email address');
144
+ * // label() → 'Anwani ya barua pepe' (evaluated at call time)
145
+ *
146
+ * @param {string} key
147
+ * @returns {Function}
148
+ */
149
+ function lazy__(key) {
150
+ return () => Trans.translate(key);
151
+ }
152
+
153
+ module.exports = {
154
+ // Singleton for locale control + advanced usage
155
+ Trans,
156
+
157
+ // Shorthand functions
158
+ __,
159
+ _n,
160
+ _p,
161
+ _f,
162
+ _fn,
163
+ lazy__,
164
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * English (en) — source language catalogue.
5
+ *
6
+ * For the source language this file acts as documentation:
7
+ * it lists every translatable string in the application.
8
+ * Values are the same as keys — no actual translation needed.
9
+ *
10
+ * Other locale files only need to include keys where the translation
11
+ * differs. Missing keys fall back to this file, then to the raw key.
12
+ *
13
+ * ── Auth ──────────────────────────────────────────────────────────────────
14
+ */
15
+ module.exports = {
16
+
17
+ // ── Auth / validation ────────────────────────────────────────────────────
18
+ 'Email is required': 'Email is required',
19
+ 'Password is required': 'Password is required',
20
+ 'Invalid email or password': 'Invalid email or password',
21
+ 'Your account has been deactivated': 'Your account has been deactivated',
22
+ 'Email already in use': 'Email already in use',
23
+ 'Password must be at least 8 characters': 'Password must be at least 8 characters',
24
+ 'Passwords do not match': 'Passwords do not match',
25
+
26
+ // ── CRUD messages ────────────────────────────────────────────────────────
27
+ '%s created successfully': '%s created successfully',
28
+ '%s updated successfully': '%s updated successfully',
29
+ '%s deleted': '%s deleted',
30
+ 'No records found': 'No records found',
31
+ 'Record not found': 'Record not found',
32
+
33
+ // ── Pagination ───────────────────────────────────────────────────────────
34
+ 'Showing %d to %d of %d results': 'Showing %d to %d of %d results',
35
+ 'Previous': 'Previous',
36
+ 'Next': 'Next',
37
+
38
+ // ── Plural examples ──────────────────────────────────────────────────────
39
+ // Array format: [singular_form, plural_form, ...]
40
+ 'You have %d message': ['You have %d message', 'You have %d messages'],
41
+ '%d item selected': ['%d item selected', '%d items selected'],
42
+ '%d record deleted': ['%d record deleted', '%d records deleted'],
43
+
44
+ // ── Contextual examples (context|key) ────────────────────────────────────
45
+ 'menu|File': 'File',
46
+ 'menu|Edit': 'Edit',
47
+ 'status|active': 'Active',
48
+ 'status|inactive': 'Inactive',
49
+ 'status|pending': 'Pending',
50
+
51
+ // ── Named interpolation examples ─────────────────────────────────────────
52
+ 'Welcome, {name}!': 'Welcome, {name}!',
53
+ 'Hello, {name}. You have {count} notifications.':
54
+ 'Hello, {name}. You have {count} notifications.',
55
+ };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Swahili (sw) translation catalogue.
5
+ *
6
+ * Only include keys where the translation differs from the source.
7
+ * Missing keys automatically fall back to the fallback locale (en).
8
+ */
9
+ module.exports = {
10
+
11
+ // ── Auth / validation ────────────────────────────────────────────────────
12
+ 'Email is required': 'Barua pepe inahitajika',
13
+ 'Password is required': 'Nywila inahitajika',
14
+ 'Invalid email or password': 'Barua pepe au nywila si sahihi',
15
+ 'Your account has been deactivated': 'Akaunti yako imezimwa',
16
+ 'Email already in use': 'Barua pepe hiyo tayari inatumika',
17
+ 'Password must be at least 8 characters': 'Nywila lazima iwe na herufi angalau 8',
18
+ 'Passwords do not match': 'Nywila hazifanani',
19
+
20
+ // ── CRUD messages ────────────────────────────────────────────────────────
21
+ '%s created successfully': '%s imeundwa kikamilifu',
22
+ '%s updated successfully': '%s imesasishwa kikamilifu',
23
+ '%s deleted': '%s imefutwa',
24
+ 'No records found': 'Hakuna rekodi zilizopatikana',
25
+ 'Record not found': 'Rekodi haijapatikana',
26
+
27
+ // ── Pagination ───────────────────────────────────────────────────────────
28
+ 'Showing %d to %d of %d results': 'Inaonyesha %d hadi %d kati ya %d matokeo',
29
+ 'Previous': 'Iliyotangulia',
30
+ 'Next': 'Inayofuata',
31
+
32
+ // ── Plural forms (Swahili uses same form for all counts) ─────────────────
33
+ 'You have %d message': ['Una ujumbe %d', 'Una ujumbe %d'],
34
+ '%d item selected': ['%d kipengele kimechaguliwa', '%d vipengele vimechaguliwa'],
35
+ '%d record deleted': ['Rekodi %d imefutwa', 'Rekodi %d zimefutwa'],
36
+
37
+ // ── Contextual ───────────────────────────────────────────────────────────
38
+ 'menu|File': 'Faili',
39
+ 'menu|Edit': 'Hariri',
40
+ 'status|active': 'Amilifu',
41
+ 'status|inactive':'Haifanyi kazi',
42
+ 'status|pending': 'Inasubiri',
43
+
44
+ // ── Named interpolation ──────────────────────────────────────────────────
45
+ 'Welcome, {name}!': 'Karibu, {name}!',
46
+ 'Hello, {name}. You have {count} notifications.':
47
+ 'Habari {name}. Una arifa {count}.',
48
+ };
@@ -1,98 +1,134 @@
1
1
  'use strict';
2
2
 
3
- const { LEVEL_NAMES, LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD, DIM } = require('../levels');
4
-
5
- /**
6
- * PrettyFormatter
7
- *
8
- * Colourful, human-readable output. Designed for development.
9
- * Inspired by Timber (Android) and Laravel's log formatting.
10
- *
11
- * Output:
12
- * [2026-03-15 12:00:00] I UserController User #5 logged in
13
- * [2026-03-15 12:00:01] E Database Connection refused { host: 'localhost' }
14
- * [2026-03-15 12:00:02] W Auth Token expiring soon
15
- *
16
- * WTF level also prints the full stack trace.
17
- */
3
+ const { LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD } = require('../levels');
4
+
5
+ const SEP = ' ';
6
+ const TAG_WIDTH = 18;
7
+
8
+ // Matches /absolute/path/to/file.js or /absolute/path/to/file.js:12:34
9
+ const FILE_PATH_RE = /(\/[^\s)'"]+\.(js|ts|json|mjs|cjs)(?::\d+(?::\d+)?)?)/g;
10
+ // Matches http(s):// URLs
11
+ const URL_RE = /(https?:\/\/[^\s)'"]+)/g;
12
+
13
+ function linkify(text, dim, useColour) {
14
+ if (!useColour) return text;
15
+
16
+ text = text.replace(FILE_PATH_RE, (match) => {
17
+ const filePath = match.replace(/(:\d+)+$/, '');
18
+ const uri = `file://${filePath}`;
19
+ return `${RESET}\x1b]8;;${uri}\x1b\\${match}\x1b]8;;\x1b\\`;
20
+ });
21
+
22
+ text = text.replace(URL_RE, (match) => {
23
+ return `${RESET}\x1b]8;;${match}\x1b\\${match}\x1b]8;;\x1b\\`;
24
+ });
25
+
26
+ return text;
27
+ }
28
+
18
29
  class PrettyFormatter {
19
- /**
20
- * @param {object} options
21
- * @param {boolean} [options.timestamp=true] — show timestamp
22
- * @param {boolean} [options.tag=true] — show tag/component name
23
- * @param {boolean} [options.colour=true] — ANSI colour (disable for pipes/files)
24
- * @param {string} [options.timestampFormat] — 'iso' | 'short' (default: 'short')
25
- */
26
30
  constructor(options = {}) {
27
31
  this.showTimestamp = options.timestamp !== false;
28
32
  this.showTag = options.tag !== false;
29
33
  this.colour = options.colour !== false;
30
34
  this.tsFormat = options.timestampFormat || 'short';
35
+ this.tagWidth = options.tagWidth || TAG_WIDTH;
31
36
  }
32
37
 
33
38
  format(entry) {
34
39
  const { level, tag, message, context, error } = entry;
35
40
 
36
- const c = this.colour ? LEVEL_COLOURS[level] : '';
37
- const r = this.colour ? RESET : '';
38
- const b = this.colour ? BOLD : '';
39
- const d = this.colour ? '\x1b[2m' : '';
40
- const lvl = LEVEL_TAGS[level] || '?';
41
+ const c = this.colour ? (LEVEL_COLOURS[level] || '') : '';
42
+ const r = this.colour ? RESET : '';
43
+ const b = this.colour ? BOLD : '';
44
+ const d = this.colour ? '\x1b[2m' : '';
45
+ const lvl = LEVEL_TAGS[level] || '?';
41
46
 
42
- const parts = [];
47
+ // ── 1. Measure plain prefix once ─────────────────────────────────────────
48
+ const ts = this._timestamp();
49
+ const plainCols = [];
50
+ if (this.showTimestamp) plainCols.push(`[${ts}]`);
51
+ plainCols.push(lvl);
52
+ if (this.showTag && tag) plainCols.push(tag.padEnd(this.tagWidth));
43
53
 
44
- // Timestamp
45
- if (this.showTimestamp) {
46
- const ts = this._timestamp();
47
- parts.push(`${d}[${ts}]${r}`);
48
- }
54
+ const indentWidth = plainCols.join(SEP).length + SEP.length;
55
+ const indent = ' '.repeat(indentWidth);
49
56
 
50
- // Level tag (single letter, coloured)
51
- parts.push(`${c}${b}${lvl}${r}`);
57
+ // ── 2. Coloured prefix ───────────────────────────────────────────────────
58
+ const colCols = [];
59
+ if (this.showTimestamp) colCols.push(`${d}[${ts}]${r}`);
60
+ colCols.push(`${c}${b}${lvl}${r}`);
61
+ if (this.showTag && tag) colCols.push(`${b}${tag.padEnd(this.tagWidth)}${r}`);
52
62
 
53
- // Component/tag
54
- if (this.showTag && tag) {
55
- const tagStr = tag.padEnd(18);
56
- parts.push(`${b}${tagStr}${r}`);
57
- }
63
+ const prefix = colCols.join(SEP) + SEP;
58
64
 
59
- // Message (handle multi-line)
60
- const lines = message.split('\n');
61
- parts.push(`${c}${lines[0]}${r}`);
65
+ // ── 3. Terminal width ────────────────────────────────────────────────────
66
+ const termWidth = (process.stdout.columns || 120);
67
+ const msgWidth = termWidth - indentWidth;
62
68
 
63
- let output = parts.join(' ');
69
+ // ── 4. Collect all logical lines ─────────────────────────────────────────
70
+ const logicalLines = [];
64
71
 
65
- // Continuation lines (aligned with first line)
66
- if (lines.length > 1) {
67
- const prefix = parts.slice(0, -1).map(p => p.replace(/\x1b\[[0-9;]*m/g, '')).join(' ');
68
- const indent = ' '.repeat(prefix.length + 2);
69
- for (let i = 1; i < lines.length; i++) {
70
- output += `\n${indent}${c}${lines[i]}${r}`;
71
- }
72
- }
72
+ for (const l of String(message).split('\n')) logicalLines.push({ text: l, dim: false });
73
73
 
74
- // Context object
75
- if (context !== undefined && context !== null) {
76
- const ctx = typeof context === 'object'
77
- ? JSON.stringify(context, null, 0)
78
- : String(context);
79
- output += ` ${d}${ctx}${r}`;
74
+ if (context != null) {
75
+ const ctx = typeof context === 'object' ? JSON.stringify(context) : String(context);
76
+ logicalLines.push({ text: ctx, dim: true });
80
77
  }
81
78
 
82
- // Error stack
83
79
  if (error instanceof Error) {
84
- output += `\n${d}${error.stack || error.message}${r}`;
80
+ for (const l of (error.stack || error.message).split('\n'))
81
+ logicalLines.push({ text: l, dim: true });
82
+ }
83
+
84
+ // ── 5. Hard-wrap (skip stack frames so paths stay intact) ────────────────
85
+ const wrappedLines = [];
86
+
87
+ for (const { text, dim } of logicalLines) {
88
+ const isStackFrame = dim && /^\s*at /.test(text);
89
+
90
+ if (isStackFrame || text.length <= msgWidth) {
91
+ wrappedLines.push({ text, dim });
92
+ continue;
93
+ }
94
+
95
+ const words = text.split(' ');
96
+ let chunk = '';
97
+ for (const word of words) {
98
+ if (chunk.length === 0) {
99
+ if (word.length > msgWidth) {
100
+ for (let i = 0; i < word.length; i += msgWidth)
101
+ wrappedLines.push({ text: word.slice(i, i + msgWidth), dim });
102
+ } else {
103
+ chunk = word;
104
+ }
105
+ } else if (chunk.length + 1 + word.length <= msgWidth) {
106
+ chunk += ' ' + word;
107
+ } else {
108
+ wrappedLines.push({ text: chunk, dim });
109
+ chunk = word;
110
+ }
111
+ }
112
+ if (chunk.length) wrappedLines.push({ text: chunk, dim });
85
113
  }
86
114
 
87
- // WTF: print big warning banner
115
+ // ── 6. Render + linkify ───────────────────────────────────────────────────
116
+ const rendered = wrappedLines.map((line, i) => {
117
+ const col = line.dim ? d : c;
118
+ const text = linkify(line.text, line.dim, this.colour);
119
+ if (i === 0) return `${prefix}${col}${text}${r}`;
120
+ return `${indent}${col}${text}${r}`;
121
+ }).join('\n');
122
+
123
+ // ── 7. WTF banner ─────────────────────────────────────────────────────────
88
124
  if (level === 5) {
89
- const banner = this.colour
125
+ const bar = this.colour
90
126
  ? `\x1b[35m\x1b[1m${'━'.repeat(60)}\x1b[0m`
91
127
  : '━'.repeat(60);
92
- output = `${banner}\n${output}\n${banner}`;
128
+ return `${bar}\n${rendered}\n${bar}`;
93
129
  }
94
130
 
95
- return output;
131
+ return rendered;
96
132
  }
97
133
 
98
134
  _timestamp() {
@@ -102,4 +138,4 @@ class PrettyFormatter {
102
138
  }
103
139
  }
104
140
 
105
- module.exports = PrettyFormatter;
141
+ module.exports = PrettyFormatter;
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * System migration: 0001_users
5
+ *
6
+ * Previously created the users table directly.
7
+ *
8
+ * As of Millas 0.3+, the users table is owned by the APP — not the framework.
9
+ * AuthUser is now fully abstract (no table). The app defines its own User model
10
+ * that extends AuthUser and owns whatever table it wants (typically 'users').
11
+ *
12
+ * This migration is kept as a no-op so existing projects that depend on
13
+ * ['system', '0001_users'] don't break. It creates nothing.
14
+ *
15
+ * Equivalent to Django's pattern: AbstractUser has no table; your custom
16
+ * User model creates its own table via app migrations.
17
+ */
18
+ module.exports = {
19
+ dependencies: [],
20
+ operations: [],
21
+ };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const { CreateModel } = require('../../orm/migration/operations');
4
+
5
+ /**
6
+ * System migration: millas_admin_log
7
+ * Equivalent to Django's django_admin_log.
8
+ */
9
+ module.exports = {
10
+ dependencies: [['system', '0001_users']],
11
+
12
+ operations: [
13
+ new CreateModel('millas_admin_log', {
14
+ id: { type: 'id', unsigned: true, nullable: false, unique: false, default: null, max: null, enumValues: null, references: null, precision: null, scale: null },
15
+ user_id: { type: 'integer', unsigned: true, nullable: true, unique: false, default: null, max: null, enumValues: null, references: { table: 'users', column: 'id', onDelete: 'SET NULL' }, precision: null, scale: null },
16
+ user_email: { type: 'string', max: 255, nullable: true, unique: false, default: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
17
+ resource: { type: 'string', max: 100, nullable: false, unique: false, default: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
18
+ record_id: { type: 'string', max: 100, nullable: true, unique: false, default: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
19
+ action: { type: 'enum', enumValues: ['create','update','delete'], nullable: false, unique: false, default: null, max: null, unsigned: false, references: null, precision: null, scale: null },
20
+ label: { type: 'string', max: 255, nullable: true, unique: false, default: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
21
+ change_msg: { type: 'text', nullable: true, unique: false, default: null, max: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
22
+ created_at: { type: 'timestamp', nullable: true, unique: false, default: null, max: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null },
23
+ }),
24
+ ],
25
+ };