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
@@ -0,0 +1,372 @@
1
+ 'use strict';
2
+
3
+ const { widgetRegistry } = require('./WidgetRegistry');
4
+
5
+ /**
6
+ * FormGenerator
7
+ *
8
+ * Derives a complete, validated form schema from:
9
+ * 1. Model field metadata (via Model.getFields())
10
+ * 2. AdminResource configuration (fields(), readonlyFields, fieldsets, widgets)
11
+ * 3. WidgetRegistry (type → widget mapping)
12
+ *
13
+ * The output is a FormSchema — a plain serialisable object that templates
14
+ * consume directly. No HTML is generated here.
15
+ *
16
+ * ── Usage ─────────────────────────────────────────────────────────────────────
17
+ *
18
+ * // In Admin.js — replacing the existing _formFields() call
19
+ * const schema = FormGenerator.fromResource(R, { record, isEdit });
20
+ * res.render('pages/form.njk', { ...ctx, formFields: schema.fields });
21
+ *
22
+ * ── What FormGenerator adds over the current _formFields() ───────────────────
23
+ *
24
+ * ✅ Widget type resolution via WidgetRegistry (customisable per field)
25
+ * ✅ Per-resource widget overrides (static widgets = { body: { type:'richtext' } })
26
+ * ✅ Validation rule derivation from field definitions
27
+ * ✅ Step/min/max attributes derived from field definitions (e.g. decimal scale)
28
+ * ✅ Coerce function available for each field (used by _sanitise)
29
+ * ✅ FormValidator.validate() — validates a full data object before ORM write
30
+ * ✅ Consistent field ordering: fieldsets respected, id always excluded
31
+ *
32
+ * ── FormSchema shape ──────────────────────────────────────────────────────────
33
+ *
34
+ * {
35
+ * fields: [
36
+ * {
37
+ * name: string,
38
+ * label: string,
39
+ * type: string, // widget type (text|number|select|checkbox|…)
40
+ * ormType: string, // original ORM field type
41
+ * required: boolean,
42
+ * nullable: boolean,
43
+ * readonly: boolean,
44
+ * hidden: boolean,
45
+ * tab: string|null,
46
+ * fieldset: string|null,
47
+ * span: string|null, // 'full'|'third'|null
48
+ * options: array|null, // for select/radio
49
+ * placeholder: string|null,
50
+ * help: string|null,
51
+ * min: number|null,
52
+ * max: number|null,
53
+ * step: number|null,
54
+ * prepopulate: string|null,
55
+ * isReadonly: boolean,
56
+ * _isFieldset: boolean, // sentinel: fieldset heading row
57
+ * },
58
+ * ...
59
+ * ],
60
+ * tabs: string[], // unique tab names in order
61
+ * hasTab: boolean,
62
+ * }
63
+ */
64
+ class FormGenerator {
65
+
66
+ /**
67
+ * Derive a FormSchema from an AdminResource class.
68
+ *
69
+ * @param {class} Resource — AdminResource subclass
70
+ * @param {object} [opts]
71
+ * @param {object} [opts.record={}] — existing record data (for edit forms)
72
+ * @param {boolean}[opts.isEdit=false]
73
+ * @returns {FormSchema}
74
+ */
75
+ static fromResource(Resource, { record = {}, isEdit = false } = {}) {
76
+ const readonlySet = new Set(Resource.readonlyFields || []);
77
+ const prepopFields = Resource.prepopulatedFields || {};
78
+ // Per-resource widget overrides: static widgets = { fieldName: { type: 'richtext' } }
79
+ const widgetOverrides = Resource.widgets || {};
80
+
81
+ // Get the merged model field map for widget/validation metadata
82
+ const modelFieldMap = Resource.model
83
+ ? (typeof Resource.model.getFields === 'function'
84
+ ? Resource.model.getFields()
85
+ : (Resource.model.fields || {}))
86
+ : {};
87
+
88
+ let currentTab = null;
89
+ let currentFieldset = null;
90
+ const fields = [];
91
+ const tabNames = [];
92
+
93
+ for (const f of Resource.fields()) {
94
+
95
+ // ── Tab separator ─────────────────────────────────────────────────────
96
+ if (f._type === 'tab') {
97
+ currentTab = f._label;
98
+ currentFieldset = null;
99
+ if (!tabNames.includes(currentTab)) tabNames.push(currentTab);
100
+ continue;
101
+ }
102
+
103
+ // ── Fieldset heading ──────────────────────────────────────────────────
104
+ if (f._type === 'fieldset') {
105
+ currentFieldset = f._label;
106
+ fields.push({ _isFieldset: true, label: f._label, tab: currentTab });
107
+ continue;
108
+ }
109
+
110
+ // ── Skip id and list-only fields from forms ───────────────────────────
111
+ if (f._type === 'id' || f._listOnly || f._hidden) continue;
112
+
113
+ const name = f._name;
114
+
115
+ // ── FK / M2M fields — use AdminField metadata directly ───────────────
116
+ // AdminField.fromModelField() already resolved the correct widget type
117
+ // ('fk' or 'm2m') and the fkResource slug from the model's references.
118
+ // FormGenerator must respect that instead of re-deriving from ormType,
119
+ // because modelFieldMap uses the accessor name ('landlord') while the
120
+ // AdminField uses the column name ('landlord_id') — they can't be matched
121
+ // by name, and even if they could, ormType='integer' would give widget='number'.
122
+ if (f._type === 'fk') {
123
+ const isReadonly = readonlySet.has(name) || f._readonly || false;
124
+ fields.push({
125
+ name,
126
+ label: f._label,
127
+ type: 'fk',
128
+ ormType: 'integer',
129
+ fkResource: f._fkResource || null,
130
+ required: !f._nullable,
131
+ nullable: !!f._nullable,
132
+ readonly: isReadonly,
133
+ isReadonly,
134
+ hidden: false,
135
+ tab: currentTab,
136
+ fieldset: currentFieldset,
137
+ span: f._span || null,
138
+ options: null,
139
+ placeholder: f._placeholder || null,
140
+ help: f._help || null,
141
+ min: null,
142
+ max: null,
143
+ step: null,
144
+ colors: {},
145
+ isLink: false,
146
+ prepopulate: null,
147
+ _coerce: widgetRegistry.resolve('fk').coerce,
148
+ _validate: widgetRegistry.resolve('fk').validate,
149
+ });
150
+ continue;
151
+ }
152
+
153
+ if (f._type === 'm2m') {
154
+ const isReadonly = readonlySet.has(name) || f._readonly || false;
155
+ fields.push({
156
+ name,
157
+ label: f._label,
158
+ type: 'm2m',
159
+ ormType: 'm2m',
160
+ fkResource: f._m2mResource || null,
161
+ required: false,
162
+ nullable: true,
163
+ readonly: isReadonly,
164
+ isReadonly,
165
+ hidden: false,
166
+ tab: currentTab,
167
+ fieldset: currentFieldset,
168
+ span: 'full',
169
+ options: null,
170
+ placeholder: null,
171
+ help: f._help || null,
172
+ min: null,
173
+ max: null,
174
+ step: null,
175
+ colors: {},
176
+ isLink: false,
177
+ prepopulate: null,
178
+ _coerce: widgetRegistry.resolve('m2m').coerce,
179
+ _validate: widgetRegistry.resolve('m2m').validate,
180
+ });
181
+ continue;
182
+ }
183
+
184
+ // ── All other field types ─────────────────────────────────────────────
185
+ // AdminField carries its own _type (e.g. 'boolean', 'date', 'json',
186
+ // 'email', 'password', 'url', 'phone', 'color', 'richtext', 'select').
187
+ // For these, the AdminField._type IS the widget key — trust it directly
188
+ // rather than re-deriving from modelFieldMap which may not find the field
189
+ // (e.g. custom AdminField.boolean('active') with a name that doesn't
190
+ // match the model's field map exactly).
191
+ //
192
+ // AdminField types that need direct resolution (no ORM equivalent):
193
+ // Map AdminField._type → WidgetRegistry key.
194
+ // AdminField types that don't match ORM type names get aliased here.
195
+ // ── Type resolution ──────────────────────────────────────────────────────
196
+ // Two separate concerns:
197
+ // ormType → WidgetRegistry key (coerce/validate functions)
198
+ // templateType → what field.type the template sees
199
+ //
200
+ // Admin-display types (color, richtext, phone, badge, image) have no ORM
201
+ // equivalent. They map to a string/text registry entry for coerce/validate
202
+ // but the template must receive the original AdminField type to render
203
+ // the correct widget (color picker, WYSIWYG, tel input, etc.).
204
+
205
+ // Maps AdminField._type → WidgetRegistry key
206
+ const REGISTRY_TYPE = {
207
+ boolean: 'boolean',
208
+ date: 'date',
209
+ datetime: 'timestamp',
210
+ timestamp:'timestamp',
211
+ json: 'json',
212
+ email: 'email',
213
+ url: 'url',
214
+ password: 'password',
215
+ phone: 'string', // template renders <input type="tel">
216
+ color: 'string', // template renders color picker
217
+ richtext: 'text', // template renders WYSIWYG
218
+ badge: 'string', // display-only, always readonly on forms
219
+ image: 'string', // display-only URL, always readonly on forms
220
+ select: 'enum',
221
+ number: 'integer',
222
+ textarea: 'text',
223
+ uuid: 'uuid',
224
+ };
225
+
226
+ // These AdminField types must reach the template with their original type
227
+ // string intact — the template switches on it to render the right widget.
228
+ // 'boolean' is intentionally excluded — the template checks 'checkbox'
229
+ // (widget.type from WidgetRegistry), not 'boolean' (AdminField._type).
230
+ const PRESERVE_TYPE = new Set([
231
+ 'color', 'richtext', 'phone', 'badge', 'image',
232
+ 'date', 'datetime', 'email', 'url', 'password',
233
+ 'json', 'number', 'textarea', 'select',
234
+ ]);
235
+
236
+ const ormType = REGISTRY_TYPE[f._type] || modelFieldMap[name]?.type || 'string';
237
+ const fieldDef = modelFieldMap[name] || {};
238
+
239
+ // Resolve widget for coerce/validate — per-field override wins
240
+ const override = widgetOverrides[name];
241
+ const widget = override
242
+ ? { ...widgetRegistry.resolve(ormType, fieldDef), ...override }
243
+ : widgetRegistry.resolve(ormType, fieldDef);
244
+
245
+ // badge and image have no meaningful form input — always readonly
246
+ const isBadgeOrImage = f._type === 'badge' || f._type === 'image';
247
+ const isReadonly = isBadgeOrImage || readonlySet.has(name) || f._readonly || false;
248
+
249
+ // Template sees the original AdminField type for display types,
250
+ // otherwise the widget type resolved from the registry.
251
+ const templateType = PRESERVE_TYPE.has(f._type) ? f._type : widget.type;
252
+
253
+ // Derive step from decimal scale (e.g. scale=2 → step=0.01)
254
+ let step = null;
255
+ if (ormType === 'decimal' || ormType === 'float') {
256
+ const scale = fieldDef.scale ?? 2;
257
+ step = parseFloat((Math.pow(10, -scale)).toFixed(scale));
258
+ } else if (ormType === 'integer' || ormType === 'bigInteger') {
259
+ step = 1;
260
+ }
261
+
262
+ // Options for select/enum
263
+ let options = f._options || null;
264
+ if (!options && ormType === 'enum' && fieldDef.enumValues) {
265
+ options = fieldDef.enumValues.map(v => ({ value: v, label: v }));
266
+ }
267
+
268
+ fields.push({
269
+ name,
270
+ label: f._label,
271
+ type: templateType,
272
+ ormType,
273
+
274
+ required: !isBadgeOrImage && !f._nullable && !fieldDef.nullable,
275
+ nullable: isBadgeOrImage || !!(f._nullable || fieldDef.nullable),
276
+ readonly: isReadonly,
277
+ isReadonly,
278
+ hidden: false,
279
+
280
+ tab: currentTab,
281
+ fieldset: currentFieldset,
282
+ span: f._span || null,
283
+
284
+ options,
285
+ placeholder: f._placeholder || null,
286
+ help: f._help || null,
287
+ min: f._min ?? null,
288
+ max: f._max ?? (ormType === 'string' ? (fieldDef.max || null) : null),
289
+ step,
290
+ colors: f._colors || {},
291
+ isLink: f._isLink || false,
292
+ prepopulate: prepopFields[name] || f._prepopulate || null,
293
+
294
+ _coerce: widget.coerce || null,
295
+ _validate: widget.validate || null,
296
+ });
297
+ }
298
+
299
+ return {
300
+ fields,
301
+ tabs: tabNames,
302
+ hasTab: tabNames.length > 0,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Validate a fully coerced data object against the resource's field rules.
308
+ *
309
+ * Called by AdminResource.create() and AdminResource.update() before
310
+ * the ORM write — after _sanitise() has already coerced the types.
311
+ *
312
+ * Returns null if all valid.
313
+ * Returns { fieldName: 'error message', … } if any field fails.
314
+ *
315
+ * @param {class} Resource — AdminResource subclass
316
+ * @param {object} data — coerced data object
317
+ * @param {object} [opts]
318
+ * @param {boolean}[opts.isNew=true]
319
+ * @returns {object|null}
320
+ */
321
+ static validate(Resource, data, { isNew = true } = {}) {
322
+ const modelFieldMap = Resource.model
323
+ ? (typeof Resource.model.getFields === 'function'
324
+ ? Resource.model.getFields()
325
+ : (Resource.model.fields || {}))
326
+ : {};
327
+
328
+ const widgetOverrides = Resource.widgets || {};
329
+ const errors = {};
330
+
331
+ for (const f of Resource.fields()) {
332
+ if (f._type === 'tab' || f._type === 'fieldset') continue;
333
+ if (f._type === 'id' || f._listOnly || f._hidden) continue;
334
+
335
+ const name = f._name;
336
+ const ormType = modelFieldMap[name]?.type || 'string';
337
+ const fieldDef = modelFieldMap[name] || {};
338
+ const override = widgetOverrides[name];
339
+ const widget = override
340
+ ? { ...widgetRegistry.resolve(ormType, fieldDef), ...override }
341
+ : widgetRegistry.resolve(ormType, fieldDef);
342
+
343
+ const value = data[name];
344
+
345
+ // Skip password validation on edit if left blank (means "keep current")
346
+ if (ormType === 'password' && !isNew && (value === undefined || value === null || value === '')) {
347
+ continue;
348
+ }
349
+
350
+ if (typeof widget.validate === 'function') {
351
+ const err = widget.validate(value, { ...fieldDef, nullable: f._nullable || fieldDef.nullable });
352
+ if (err) errors[name] = err;
353
+ }
354
+ }
355
+
356
+ // Run custom validation hook on the resource if defined
357
+ // static validate(data, errors, isNew) — can add to errors or throw
358
+ if (typeof Resource.validate === 'function') {
359
+ try {
360
+ Resource.validate(data, errors, isNew);
361
+ } catch (err) {
362
+ // If validate() throws an HttpError with errors attached, use those
363
+ if (err.errors) Object.assign(errors, err.errors);
364
+ else errors._form = err.message;
365
+ }
366
+ }
367
+
368
+ return Object.keys(errors).length > 0 ? errors : null;
369
+ }
370
+ }
371
+
372
+ module.exports = { FormGenerator };
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HookRegistry + HookPipeline
5
+ *
6
+ * The Millas admin hook system — inspired by Django's signals and WordPress hooks.
7
+ *
8
+ * ── Supported events ──────────────────────────────────────────────────────────
9
+ *
10
+ * before_save fired before create or update reaches the ORM
11
+ * ctx: { data, user, isNew, resource }
12
+ * return value replaces data → allows mutation
13
+ *
14
+ * after_save fired after a successful create or update
15
+ * ctx: { record, user, isNew, resource }
16
+ *
17
+ * before_delete fired before destroy() reaches the ORM
18
+ * ctx: { record, user, resource }
19
+ * throw to abort the delete
20
+ *
21
+ * after_delete fired after a successful delete
22
+ * ctx: { id, user, resource }
23
+ *
24
+ * before_render fired before a template is rendered
25
+ * ctx: { view, templateCtx, user, resource }
26
+ * return value replaces templateCtx → allows injection
27
+ *
28
+ * after_render fired after a response is sent (fire-and-forget)
29
+ * ctx: { view, user, resource, ms }
30
+ *
31
+ * before_action fired before a bulk action handler runs
32
+ * ctx: { ids, action, user, resource }
33
+ *
34
+ * after_action fired after a bulk action completes
35
+ * ctx: { ids, action, result, user, resource }
36
+ *
37
+ * ── Hook resolution order ────────────────────────────────────────────────────
38
+ *
39
+ * 1. Global hooks (registered via AdminHooks.on()) — run first
40
+ * 2. Resource-level hooks (static methods on AdminConfig subclass)
41
+ * e.g. static async before_save(data, ctx) { ... }
42
+ *
43
+ * ── Usage ─────────────────────────────────────────────────────────────────────
44
+ *
45
+ * const { AdminHooks } = require('./HookRegistry');
46
+ *
47
+ * // Global — runs for every resource
48
+ * AdminHooks.on('after_save', async (ctx) => {
49
+ * await Cache.invalidate(ctx.resource.slug);
50
+ * });
51
+ *
52
+ * // Global — abort a delete with a business rule
53
+ * AdminHooks.on('before_delete', async (ctx) => {
54
+ * if (ctx.record.status === 'published') {
55
+ * throw new Error('Cannot delete a published record.');
56
+ * }
57
+ * });
58
+ *
59
+ * // Per-resource — defined as static methods on AdminConfig subclass
60
+ * class PostAdmin extends AdminConfig {
61
+ * static async before_save(data, ctx) {
62
+ * if (ctx.isNew) data.created_by = ctx.user?.id;
63
+ * return data; // MUST return data (possibly mutated)
64
+ * }
65
+ * static async after_save(record, ctx) {
66
+ * if (ctx.isNew) await Mailer.send('welcome', record);
67
+ * }
68
+ * static async before_delete(record, ctx) {
69
+ * if (record.is_protected) throw new Error('This record is protected.');
70
+ * }
71
+ * }
72
+ */
73
+
74
+ const VALID_EVENTS = new Set([
75
+ 'before_save',
76
+ 'after_save',
77
+ 'before_delete',
78
+ 'after_delete',
79
+ 'before_render',
80
+ 'after_render',
81
+ 'before_action',
82
+ 'after_action',
83
+ ]);
84
+
85
+ class HookRegistry {
86
+ constructor() {
87
+ /** @type {Map<string, Function[]>} */
88
+ this._hooks = new Map();
89
+ for (const event of VALID_EVENTS) {
90
+ this._hooks.set(event, []);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Register a global hook.
96
+ *
97
+ * @param {string} event — one of the VALID_EVENTS
98
+ * @param {Function} handler — async (ctx) => void | data
99
+ * @param {object} [opts]
100
+ * @param {number} [opts.priority=10] — lower runs first (like WordPress)
101
+ */
102
+ on(event, handler, { priority = 10 } = {}) {
103
+ if (!VALID_EVENTS.has(event)) {
104
+ throw new Error(
105
+ `[AdminHooks] Unknown event "${event}". ` +
106
+ `Valid events: ${[...VALID_EVENTS].join(', ')}`
107
+ );
108
+ }
109
+ if (typeof handler !== 'function') {
110
+ throw new Error(`[AdminHooks] Hook handler for "${event}" must be a function.`);
111
+ }
112
+
113
+ handler._priority = priority;
114
+ this._hooks.get(event).push(handler);
115
+ // Keep sorted by priority after each insertion
116
+ this._hooks.get(event).sort((a, b) => (a._priority || 10) - (b._priority || 10));
117
+ return this; // chainable
118
+ }
119
+
120
+ /**
121
+ * Remove a previously registered global hook.
122
+ * Pass the exact same function reference used in .on().
123
+ */
124
+ off(event, handler) {
125
+ if (!this._hooks.has(event)) return this;
126
+ const list = this._hooks.get(event).filter(h => h !== handler);
127
+ this._hooks.set(event, list);
128
+ return this;
129
+ }
130
+
131
+ /**
132
+ * Remove all global hooks for an event (or all events if none specified).
133
+ * Useful in tests to reset state between test cases.
134
+ */
135
+ clear(event = null) {
136
+ if (event) {
137
+ if (this._hooks.has(event)) this._hooks.set(event, []);
138
+ } else {
139
+ for (const e of VALID_EVENTS) this._hooks.set(e, []);
140
+ }
141
+ return this;
142
+ }
143
+
144
+ /**
145
+ * Return the list of global hooks for an event.
146
+ * @param {string} event
147
+ * @returns {Function[]}
148
+ */
149
+ getHandlers(event) {
150
+ return this._hooks.get(event) || [];
151
+ }
152
+ }
153
+
154
+ // ── HookPipeline ──────────────────────────────────────────────────────────────
155
+
156
+ class HookPipeline {
157
+ /**
158
+ * Run the full hook pipeline for an event.
159
+ *
160
+ * Pipeline order:
161
+ * 1. Global hooks (from HookRegistry)
162
+ * 2. Resource-level static method on AdminConfig (if defined)
163
+ *
164
+ * For before_save:
165
+ * - ctx.data is passed to each hook
166
+ * - If a hook returns a non-undefined value, that becomes the new ctx.data
167
+ * - Final ctx.data is what reaches the ORM
168
+ *
169
+ * For before_delete / before_render / before_action:
170
+ * - Hooks may throw to abort the operation
171
+ * - Return value is used if present (for before_render: replaces templateCtx)
172
+ *
173
+ * For after_* events:
174
+ * - Fire-and-forget for after_render (errors are swallowed, never crash the request)
175
+ * - Errors in other after_* hooks are logged but don't affect the response
176
+ *
177
+ * @param {string} event — hook event name
178
+ * @param {object} ctx — context passed to all handlers
179
+ * @param {class} Resource — AdminConfig subclass (may have static hook methods)
180
+ * @param {HookRegistry} registry — the global hook registry
181
+ * @returns {Promise<object>} — possibly mutated ctx
182
+ */
183
+ static async run(event, ctx, Resource, registry) {
184
+ // ── 1. Global hooks ────────────────────────────────────────────────────
185
+ const globalHandlers = registry.getHandlers(event);
186
+
187
+ for (const handler of globalHandlers) {
188
+ try {
189
+ const result = await handler(ctx);
190
+ // For before_save: allow handler to return mutated data
191
+ if (result !== undefined && event === 'before_save') {
192
+ ctx.data = result;
193
+ }
194
+ // For before_render: allow handler to mutate templateCtx
195
+ if (result !== undefined && event === 'before_render') {
196
+ ctx.templateCtx = result;
197
+ }
198
+ } catch (err) {
199
+ if (event.startsWith('after_')) {
200
+ // after_* errors must never crash the response — log and continue
201
+ process.stderr.write(`[AdminHooks] Error in global "${event}" hook: ${err.message}\n`);
202
+ } else {
203
+ throw err; // before_* errors abort the operation
204
+ }
205
+ }
206
+ }
207
+
208
+ // ── 2. Resource-level hook method ──────────────────────────────────────
209
+ // e.g. static async before_save(data, ctx) on the AdminConfig subclass
210
+ if (Resource && typeof Resource[event] === 'function') {
211
+ try {
212
+ // Convention: first arg is the "primary subject", second is the full ctx
213
+ // before_save(data, ctx) → returns mutated data
214
+ // after_save(record, ctx) → no return needed
215
+ // before_delete(record, ctx) → throw to abort
216
+ // after_delete(id, ctx) → no return needed
217
+ // before_render(templateCtx,ctx) → returns mutated templateCtx
218
+ // before_action(ids, ctx) → throw to abort
219
+ let primaryArg;
220
+ switch (event) {
221
+ case 'before_save': primaryArg = ctx.data; break;
222
+ case 'after_save': primaryArg = ctx.record; break;
223
+ case 'before_delete': primaryArg = ctx.record; break;
224
+ case 'after_delete': primaryArg = ctx.id; break;
225
+ case 'before_render': primaryArg = ctx.templateCtx; break;
226
+ case 'after_render': primaryArg = ctx.view; break;
227
+ case 'before_action': primaryArg = ctx.ids; break;
228
+ case 'after_action': primaryArg = ctx.ids; break;
229
+ default: primaryArg = ctx;
230
+ }
231
+
232
+ const result = await Resource[event](primaryArg, ctx);
233
+
234
+ if (result !== undefined && event === 'before_save') {
235
+ ctx.data = result;
236
+ }
237
+ if (result !== undefined && event === 'before_render') {
238
+ ctx.templateCtx = result;
239
+ }
240
+ } catch (err) {
241
+ if (event.startsWith('after_')) {
242
+ process.stderr.write(`[AdminHooks] Error in ${Resource.name}.${event}: ${err.message}\n`);
243
+ } else {
244
+ throw err;
245
+ }
246
+ }
247
+ }
248
+
249
+ return ctx;
250
+ }
251
+ }
252
+
253
+ // ── Singleton global registry ─────────────────────────────────────────────────
254
+ const AdminHooks = new HookRegistry();
255
+
256
+ module.exports = { HookRegistry, HookPipeline, AdminHooks, VALID_EVENTS };