millas 0.2.13 → 0.2.14

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 (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +14 -1
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const { isShape } = require('../http/Shape');
4
+
5
+ /**
6
+ * RouteEntry
7
+ *
8
+ * A thin wrapper returned by Route.get/post/put/patch/delete/resource.
9
+ * Exposes .shape() and .fromShape() for attaching a shape definition,
10
+ * then writes it back to the registry entry.
11
+ *
12
+ * The Route instance itself is passed in so group-level chaining still
13
+ * works — .shape() returns the RouteEntry, but the Route is unaffected.
14
+ *
15
+ * ── Usage ─────────────────────────────────────────────────────────────────────
16
+ *
17
+ * Route.post('/properties', PropertyController, 'store')
18
+ * .shape({ label: 'Create property', in: { name: string().required() }, out: {} });
19
+ *
20
+ * Route.post('/properties', PropertyController, 'store')
21
+ * .fromShape(CreatePropertyShape);
22
+ */
23
+ class RouteEntry {
24
+ /**
25
+ * @param {object} entry — the raw entry object stored in RouteRegistry
26
+ * @param {Route} router — the Route instance (for method chaining back)
27
+ */
28
+ constructor(entry, router) {
29
+ this._entry = entry;
30
+ this._router = router;
31
+ }
32
+
33
+ /**
34
+ * Attach an inline shape definition to this route.
35
+ *
36
+ * Route.post('/users', UserController, 'store')
37
+ * .shape({
38
+ * label: 'Create user',
39
+ * group: 'Users',
40
+ * in: { email: email().required() },
41
+ * out: { 201: { id: 1 } },
42
+ * });
43
+ *
44
+ * The "in" schema runs as validation middleware before the handler.
45
+ * If validation fails, 422 is returned immediately.
46
+ * The handler receives clean, coerced data via { body }.
47
+ *
48
+ * @param {object} def — plain shape definition OR a shape() result
49
+ * @returns {RouteEntry}
50
+ */
51
+ shape(def) {
52
+ // Accept both raw objects and shape() factory results
53
+ // If raw object, wrap through shape() for validation + freezing
54
+ if (!isShape(def)) {
55
+ const { shape: makeShape } = require('../http/Shape');
56
+ def = makeShape(def);
57
+ }
58
+ this._entry.shape = def;
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Attach a pre-built shape from a shapes file.
64
+ * Identical to .shape() — exists for readability and convention.
65
+ *
66
+ * const { CreatePropertyShape } = require('../app/shapes/PropertyShape');
67
+ * Route.post('/properties', PropertyController, 'store')
68
+ * .fromShape(CreatePropertyShape);
69
+ *
70
+ * @param {object} shapeDefinition — result of shape() factory
71
+ * @returns {RouteEntry}
72
+ */
73
+ fromShape(shapeDefinition) {
74
+ return this.shape(shapeDefinition);
75
+ }
76
+
77
+ /**
78
+ * Add extra middleware to this specific route after registration.
79
+ * Middleware aliases are appended to the existing list.
80
+ *
81
+ * @param {string|string[]} middleware
82
+ * @returns {RouteEntry}
83
+ */
84
+ middleware(mw) {
85
+ const list = Array.isArray(mw) ? mw : [mw];
86
+ this._entry.middleware = [...(this._entry.middleware || []), ...list];
87
+ return this;
88
+ }
89
+ }
90
+
91
+ module.exports = RouteEntry;
@@ -67,7 +67,17 @@ class Router {
67
67
 
68
68
  _bindRoute(route) {
69
69
  const middlewareHandlers = this._resolveMiddleware(route.middleware || []);
70
- const terminalHandler = this._resolveTerminalHandler(
70
+
71
+ // ── Shape validation middleware ────────────────────────────────────────
72
+ // If the route has a .shape() / .fromShape() declaration, inject a
73
+ // validation middleware that runs BEFORE the handler.
74
+ // On failure → 422 immediately, handler never runs.
75
+ // On success → ctx.body is replaced with coerced, validated output.
76
+ const shapeMiddlewares = route.shape
77
+ ? this._buildShapeMiddleware(route.shape)
78
+ : [];
79
+
80
+ const terminalHandler = this._resolveTerminalHandler(
71
81
  route.handler,
72
82
  route.method,
73
83
  route.verb,
@@ -77,10 +87,70 @@ class Router {
77
87
 
78
88
  this._adapter.mountRoute(route.verb, route.path, [
79
89
  ...middlewareHandlers,
90
+ ...shapeMiddlewares,
80
91
  terminalHandler,
81
92
  ]);
82
93
  }
83
94
 
95
+ /**
96
+ * Build Express middleware functions from a shape definition.
97
+ * Returns an array of 0, 1, or 2 middleware functions
98
+ * (one for body/in, one for query) depending on what the shape declares.
99
+ *
100
+ * @param {import('../http/Shape').ShapeDefinition} shape
101
+ * @returns {Function[]}
102
+ */
103
+ _buildShapeMiddleware(shape) {
104
+ const { Validator } = require('../validation/Validator');
105
+ const middlewares = [];
106
+
107
+ // ── Body / in validation ───────────────────────────────────────────────
108
+ if (shape.in && Object.keys(shape.in).length) {
109
+ middlewares.push(async (req, res, next) => {
110
+ try {
111
+ const rawBody = req.body || {};
112
+ const clean = await Validator.validate(rawBody, shape.in);
113
+ // Replace req.body with the coerced, validated subset so the
114
+ // handler's { body } destructure gets clean data automatically.
115
+ req.body = clean;
116
+ next();
117
+ } catch (err) {
118
+ // ValidationError → 422, any other error → pass to error handler
119
+ if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
120
+ return res.status(422).json({
121
+ status: 422,
122
+ message: 'Validation failed',
123
+ errors: err.errors || {},
124
+ });
125
+ }
126
+ next(err);
127
+ }
128
+ });
129
+ }
130
+
131
+ // ── Query validation ───────────────────────────────────────────────────
132
+ if (shape.query && Object.keys(shape.query).length) {
133
+ middlewares.push(async (req, res, next) => {
134
+ try {
135
+ const clean = await Validator.validate(req.query || {}, shape.query);
136
+ req.query = clean;
137
+ next();
138
+ } catch (err) {
139
+ if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
140
+ return res.status(422).json({
141
+ status: 422,
142
+ message: 'Validation failed',
143
+ errors: err.errors || {},
144
+ });
145
+ }
146
+ next(err);
147
+ }
148
+ });
149
+ }
150
+
151
+ return middlewares;
152
+ }
153
+
84
154
  _resolveMiddleware(list) {
85
155
  return list.map(alias => {
86
156
  try {
@@ -262,6 +262,141 @@ module.exports = {
262
262
  return write(filePath, content);
263
263
  }
264
264
 
265
+
266
+ async function makeShape(name) {
267
+ const baseName = name.endsWith('Shape') ? name : (name + 'Shape');
268
+ const className = pascalCase(baseName);
269
+ const filePath = resolveAppPath('app/shapes', className + '.js');
270
+ const groupBase = className.replace(/Shape$/, '');
271
+ const group = groupBase + 's';
272
+
273
+ const lines = [
274
+ "'use strict';",
275
+ '',
276
+ "const { shape } = require('millas/core/http');",
277
+ "const { string, number, boolean, array, email, date } = require('millas/core/validation');",
278
+ '',
279
+ '/**',
280
+ ' * ' + className,
281
+ ' *',
282
+ ' * Route input/output contracts for ' + groupBase + ' endpoints.',
283
+ ' *',
284
+ ' * Usage:',
285
+ ' * Route.post(\'/path\', ' + groupBase + 'Controller, \'store\').fromShape(Create' + groupBase + 'Shape);',
286
+ ' * Route.put(\'/path/:id\', ' + groupBase + 'Controller, \'update\').fromShape(Update' + groupBase + 'Shape);',
287
+ ' *',
288
+ ' * The "in" schema validates the request body before the handler runs.',
289
+ ' * Failures return 422 automatically. Use { body } in your handler — it is clean.',
290
+ ' */',
291
+ '',
292
+ 'const Create' + groupBase + 'Shape = shape({',
293
+ " label: 'Create " + groupBase.toLowerCase() + "',",
294
+ " group: '" + group + "',",
295
+ ' description: null,',
296
+ ' in: {',
297
+ ' // name: string().required().max(200).example(\'My ' + groupBase + '\'),',
298
+ ' // email: email().required().example(\'user@example.com\'),',
299
+ ' // count: number().required().min(1).example(1),',
300
+ ' // active: boolean().optional().example(true),',
301
+ " // tags: array().of(string()).optional().example(['tag1', 'tag2']),",
302
+ " // type: string().required().oneOf(['option_a', 'option_b']),",
303
+ ' },',
304
+ ' out: {',
305
+ ' 201: { id: 1 },',
306
+ " 422: { message: 'Validation failed', errors: {} },",
307
+ ' },',
308
+ '});',
309
+ '',
310
+ 'const Update' + groupBase + 'Shape = shape({',
311
+ " label: 'Update " + groupBase.toLowerCase() + "',",
312
+ " group: '" + group + "',",
313
+ ' in: {',
314
+ ' // name: string().optional().max(200),',
315
+ ' },',
316
+ ' out: {',
317
+ ' 200: { id: 1 },',
318
+ " 422: { message: 'Validation failed', errors: {} },",
319
+ ' },',
320
+ '});',
321
+ '',
322
+ 'module.exports = { Create' + groupBase + 'Shape, Update' + groupBase + 'Shape };',
323
+ ];
324
+
325
+ return write(filePath, lines.join('\n'));
326
+ }
327
+ async function makeCommand(name) {
328
+ // If name contains ':' it's a namespaced signature like 'email:SendDigest'.
329
+ // Split off the namespace prefix and use only the last segment for the
330
+ // class name / filename, but keep the full input as the signature.
331
+ const parts = name.split(':');
332
+ const basePart = parts[parts.length - 1]; // e.g. 'SendDigest'
333
+ const cleanBase = basePart.replace(/Command$/i, ''); // strip trailing Command
334
+ const className = pascalCase(cleanBase) + 'Command'; // e.g. 'SendDigestCommand'
335
+
336
+ // Build signature from the full name:
337
+ // 'email:SendDigest' → 'email:digest'
338
+ // 'SendDigest' → 'send-digest'
339
+ // 'send-digest' → 'send-digest'
340
+ const signatureParts = name
341
+ .replace(/Command$/i, '')
342
+ .split(':')
343
+ .map((seg, i, arr) => {
344
+ // Last segment: strip camelCase — 'SendDigest' → 'send-digest'
345
+ if (i === arr.length - 1) {
346
+ return seg
347
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
348
+ .toLowerCase();
349
+ }
350
+ // Namespace segments: lowercase as-is
351
+ return seg.toLowerCase();
352
+ });
353
+ const signature = signatureParts.join(':');
354
+
355
+ const filePath = resolveAppPath('app/commands', `${className}.js`);
356
+
357
+ const content = `'use strict';
358
+
359
+ const { Command } = require('millas/console');
360
+
361
+ /**
362
+ * ${className}
363
+ *
364
+ * Run with: millas call ${signature}
365
+ */
366
+ class ${className} extends Command {
367
+ static signature = '${signature}';
368
+ static description = 'Description of ${signature}';
369
+
370
+ // Optional: positional arguments
371
+ // static args = [
372
+ // { name: 'target', description: 'The target to act on', default: 'all' },
373
+ // ];
374
+
375
+ // Optional: named options / flags
376
+ // static options = [
377
+ // { flag: '--dry-run', description: 'Preview without making changes' },
378
+ // { flag: '--limit <n>', description: 'Max items to process', default: '50' },
379
+ // ];
380
+
381
+ async handle() {
382
+ // const target = this.argument('target');
383
+ // const limit = this.option('limit');
384
+ // const dry = this.option('dryRun');
385
+
386
+ this.info('Running ${signature}...');
387
+
388
+ // Your command logic here
389
+
390
+ this.success('Done.');
391
+ }
392
+ }
393
+
394
+ module.exports = ${className};
395
+ `;
396
+
397
+ return write(filePath, content);
398
+ }
399
+
265
400
  module.exports = {
266
401
  makeController,
267
402
  makeModel,
@@ -269,4 +404,6 @@ module.exports = {
269
404
  makeService,
270
405
  makeJob,
271
406
  makeMigration,
272
- };
407
+ makeShape,
408
+ makeCommand,
409
+ };
@@ -161,6 +161,18 @@ module.exports = {
161
161
  // Set use_i18n: true to enable the translation system.
162
162
  // Then run: millas lang:publish <locale>
163
163
  use_i18n: false,
164
+
165
+ // ── CORS ──────────────────────────────────────────────────────────────────
166
+ // Uncomment and call .withCors() in bootstrap/app.js to enable.
167
+ // All values shown are the defaults — only include what you need to change.
168
+ //
169
+ // cors: {
170
+ // origins: ['*'], // or ['https://app.example.com']
171
+ // methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
172
+ // headers: ['Content-Type', 'Authorization', 'X-Requested-With'],
173
+ // credentials: false, // true requires explicit origins, not '*'
174
+ // maxAge: 86400, // preflight cache in seconds
175
+ // },
164
176
  };
165
177
  `,
166
178
 
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Serializer
5
+ *
6
+ * Controls the exact shape of API output — what fields go out, what's
7
+ * nested, and what's hidden. Sits between your model and your JSON response.
8
+ *
9
+ * ── Why use a Serializer instead of jsonify(model) ───────────────────────────
10
+ *
11
+ * jsonify(user) — dumps every column, relies on model.hidden
12
+ * UserSerializer.one(user) — precise whitelist, nested relations, custom fields
13
+ *
14
+ * ── Defining a Serializer ────────────────────────────────────────────────────
15
+ *
16
+ * const { Serializer } = require('millas/src/serializer/Serializer');
17
+ *
18
+ * class UserSerializer extends Serializer {
19
+ * // Whitelist of fields to include. If omitted, all fields are included
20
+ * // (minus model.hidden and any fields listed in static hidden below).
21
+ * static fields = ['id', 'name', 'email', 'role', 'created_at'];
22
+ *
23
+ * // Extra fields to exclude on top of model.hidden.
24
+ * // Only needed when static fields is not set.
25
+ * static hidden = ['internal_notes', 'stripe_customer_id'];
26
+ *
27
+ * // Nested serializers — keyed by the relation name on the model.
28
+ * // The relation must be eager-loaded (.with('roles')) before serializing.
29
+ * // If the relation is not loaded, the key is silently omitted.
30
+ * static nested = {
31
+ * roles: RoleSerializer,
32
+ * profile: ProfileSerializer,
33
+ * };
34
+ *
35
+ * // Computed fields — functions that receive the model instance and
36
+ * // return an additional value not stored on the model.
37
+ * static computed = {
38
+ * full_name: (user) => `${user.first_name} ${user.last_name}`,
39
+ * is_admin: (user) => user.role === 'admin',
40
+ * };
41
+ * }
42
+ *
43
+ * ── Usage ─────────────────────────────────────────────────────────────────────
44
+ *
45
+ * // Single record
46
+ * const user = await User.with('roles', 'profile').find(id);
47
+ * return jsonify(UserSerializer.one(user));
48
+ *
49
+ * // Collection
50
+ * const users = await User.with('roles').all();
51
+ * return jsonify(UserSerializer.many(users));
52
+ *
53
+ * // Paginated
54
+ * const result = await User.with('roles').paginate(page, perPage);
55
+ * return jsonify(UserSerializer.paginate(result));
56
+ *
57
+ * // With request context (for conditional fields)
58
+ * return jsonify(UserSerializer.one(user, { req }));
59
+ *
60
+ * ── Nested relations ──────────────────────────────────────────────────────────
61
+ *
62
+ * The relation must be eager-loaded first. If not loaded, it is skipped.
63
+ *
64
+ * // WRONG — roles not loaded, will be silently omitted
65
+ * const user = await User.find(id);
66
+ * UserSerializer.one(user);
67
+ *
68
+ * // CORRECT — roles eager-loaded, will be serialized through RoleSerializer
69
+ * const user = await User.with('roles').find(id);
70
+ * UserSerializer.one(user);
71
+ *
72
+ * ── Conditional fields ────────────────────────────────────────────────────────
73
+ *
74
+ * Override the instance method serialize(instance, ctx) for per-request logic:
75
+ *
76
+ * class UserSerializer extends Serializer {
77
+ * static fields = ['id', 'name', 'email'];
78
+ *
79
+ * serialize(instance, ctx = {}) {
80
+ * const data = super.serialize(instance, ctx);
81
+ * if (ctx.req?.user?.is_admin) {
82
+ * data.internal_notes = instance.internal_notes;
83
+ * }
84
+ * return data;
85
+ * }
86
+ * }
87
+ */
88
+ class Serializer {
89
+ /**
90
+ * Whitelist of field names to include.
91
+ * When set, ONLY these fields appear in output (plus computed + nested).
92
+ * When null/undefined, all fields are included minus hidden ones.
93
+ *
94
+ * @type {string[]|null}
95
+ */
96
+ static fields = null;
97
+
98
+ /**
99
+ * Extra fields to exclude, on top of the model's static hidden list.
100
+ * Only relevant when static fields is not set.
101
+ *
102
+ * @type {string[]}
103
+ */
104
+ static hidden = [];
105
+
106
+ /**
107
+ * Nested serializers, keyed by relation name.
108
+ * The relation must be eager-loaded before calling serialize().
109
+ * If the value on the instance is a function (lazy-loaded), it is skipped.
110
+ *
111
+ * @type {Object.<string, typeof Serializer>}
112
+ */
113
+ static nested = {};
114
+
115
+ /**
116
+ * Computed fields — functions that receive the model instance and
117
+ * optional context, returning a value to include in output.
118
+ *
119
+ * @type {Object.<string, function(instance, ctx): *>}
120
+ */
121
+ static computed = {};
122
+
123
+ // ── Core serialization ─────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Serialize a single model instance.
127
+ * Override this instance method for per-request conditional logic.
128
+ *
129
+ * @param {object} instance — model instance or plain object
130
+ * @param {object} [ctx] — optional context (e.g. { req })
131
+ * @returns {object}
132
+ */
133
+ serialize(instance, ctx = {}) {
134
+ if (!instance) return null;
135
+
136
+ const ctor = this.constructor;
137
+ const raw = typeof instance.toJSON === 'function' ? instance.toJSON() : { ...instance };
138
+ const result = {};
139
+
140
+ // ── Field whitelist or full set ─────────────────────────────────────────
141
+ if (ctor.fields && ctor.fields.length) {
142
+ // Whitelist mode — only declared fields
143
+ for (const key of ctor.fields) {
144
+ if (key in raw) {
145
+ result[key] = raw[key];
146
+ }
147
+ }
148
+ } else {
149
+ // All-fields mode — exclude serializer.hidden on top of model.hidden
150
+ // (model.hidden is already applied by toJSON())
151
+ const hiddenSet = new Set(ctor.hidden || []);
152
+ for (const [key, value] of Object.entries(raw)) {
153
+ if (!hiddenSet.has(key)) {
154
+ result[key] = value;
155
+ }
156
+ }
157
+ }
158
+
159
+ // ── Computed fields ─────────────────────────────────────────────────────
160
+ for (const [key, fn] of Object.entries(ctor.computed || {})) {
161
+ result[key] = fn(instance, ctx);
162
+ }
163
+
164
+ // ── Nested serializers ──────────────────────────────────────────────────
165
+ for (const [key, NestedSerializer] of Object.entries(ctor.nested || {})) {
166
+ const value = instance[key];
167
+
168
+ // Skip if not loaded (still a function = lazy-load accessor)
169
+ if (typeof value === 'function') continue;
170
+ // Skip if not present
171
+ if (value === undefined) continue;
172
+
173
+ const nested = new NestedSerializer();
174
+
175
+ if (value === null) {
176
+ result[key] = null;
177
+ } else if (Array.isArray(value)) {
178
+ result[key] = value.map(item => nested.serialize(item, ctx));
179
+ } else {
180
+ result[key] = nested.serialize(value, ctx);
181
+ }
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ // ── Static convenience methods ─────────────────────────────────────────────
188
+
189
+ /**
190
+ * Serialize a single model instance.
191
+ *
192
+ * UserSerializer.one(user)
193
+ * UserSerializer.one(user, { req })
194
+ *
195
+ * @param {object} instance
196
+ * @param {object} [ctx]
197
+ * @returns {object|null}
198
+ */
199
+ static one(instance, ctx = {}) {
200
+ if (!instance) return null;
201
+ return new this().serialize(instance, ctx);
202
+ }
203
+
204
+ /**
205
+ * Serialize an array of model instances.
206
+ *
207
+ * UserSerializer.many(users)
208
+ * UserSerializer.many(users, { req })
209
+ *
210
+ * @param {object[]} instances
211
+ * @param {object} [ctx]
212
+ * @returns {object[]}
213
+ */
214
+ static many(instances, ctx = {}) {
215
+ if (!instances || !instances.length) return [];
216
+ const s = new this();
217
+ return instances.map(i => s.serialize(i, ctx));
218
+ }
219
+
220
+ /**
221
+ * Serialize a paginated result from Model.paginate() or QueryBuilder.paginate().
222
+ * Preserves the pagination meta and wraps data through the serializer.
223
+ *
224
+ * const result = await User.with('roles').paginate(page, perPage);
225
+ * return jsonify(UserSerializer.paginate(result));
226
+ *
227
+ * @param {{ data: object[], meta: object }} result
228
+ * @param {object} [ctx]
229
+ * @returns {{ data: object[], meta: object }}
230
+ */
231
+ static paginate(result, ctx = {}) {
232
+ return {
233
+ data: this.many(result.data || [], ctx),
234
+ meta: result.meta || {},
235
+ };
236
+ }
237
+ }
238
+
239
+ module.exports = { Serializer };