webspresso 0.0.102 → 0.0.104

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 (91) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
@@ -5,6 +5,7 @@
5
5
 
6
6
  const { normalizeError, toErrorResponseObject } = require('./normalize');
7
7
  const { RequestAbortedError } = require('./domain');
8
+ const { escapeHtml } = require('../utils/html');
8
9
 
9
10
  /**
10
11
  * Check if the request prefers a JSON error response
@@ -60,14 +61,7 @@ function renderDefaultErrorHtml(err, isDev) {
60
61
  </html>`;
61
62
  }
62
63
 
63
- function escapeHtml(str) {
64
- return String(str || '')
65
- .replace(/&/g, '&amp;')
66
- .replace(/</g, '&lt;')
67
- .replace(/>/g, '&gt;')
68
- .replace(/"/g, '&quot;')
69
- .replace(/'/g, '&#39;');
70
- }
64
+
71
65
 
72
66
  /**
73
67
  * Create Webspresso Central Error Handler Middleware
@@ -12,10 +12,14 @@ const EventEmitter = require('events');
12
12
  * @param {string} pattern
13
13
  * @returns {RegExp}
14
14
  */
15
+ const patternRegexCache = new Map();
16
+
15
17
  function patternToRegExp(pattern) {
16
18
  if (pattern === '*' || pattern === '**') {
17
19
  return /^.*$/;
18
20
  }
21
+ const cached = patternRegexCache.get(pattern);
22
+ if (cached !== undefined) return cached;
19
23
 
20
24
  const escaped = pattern
21
25
  .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
@@ -23,7 +27,11 @@ function patternToRegExp(pattern) {
23
27
  .replace(/\*/g, '[^.]+')
24
28
  .replace(/___DOUBLE_STAR___/g, '.*');
25
29
 
26
- return new RegExp(`^${escaped}$`);
30
+ const reg = new RegExp(`^${escaped}$`);
31
+ if (patternRegexCache.size < 500) {
32
+ patternRegexCache.set(pattern, reg);
33
+ }
34
+ return reg;
27
35
  }
28
36
 
29
37
  /**
@@ -193,8 +201,9 @@ class EventDispatcher extends EventEmitter {
193
201
  // 1. Exact listeners
194
202
  if (this.exactListeners.has(event)) {
195
203
  const list = this.exactListeners.get(event);
196
- const toKeep = [];
197
- for (const item of list) {
204
+ let hasOnce = false;
205
+ for (let i = 0; i < list.length; i++) {
206
+ const item = list[i];
198
207
  if (matchesSender(item.sender, sender)) {
199
208
  matched.push({
200
209
  priority: item.priority,
@@ -207,53 +216,67 @@ class EventDispatcher extends EventEmitter {
207
216
  once: item.once,
208
217
  });
209
218
  }
210
- if (!item.once) {
211
- toKeep.push(item);
219
+ if (item.once) {
220
+ hasOnce = true;
212
221
  }
213
222
  }
214
- this.exactListeners.set(event, toKeep);
223
+ if (hasOnce) {
224
+ this.exactListeners.set(event, list.filter((l) => !l.once));
225
+ }
215
226
  }
216
227
 
217
228
  // 2. Wildcard listeners
218
- const remainingWildcards = [];
219
- for (const item of this.wildcardListeners) {
220
- if (item.regex.test(event) && matchesSender(item.sender, sender)) {
229
+ if (this.wildcardListeners.length > 0) {
230
+ let hasOnce = false;
231
+ for (let i = 0; i < this.wildcardListeners.length; i++) {
232
+ const item = this.wildcardListeners[i];
233
+ if (item.regex.test(event) && matchesSender(item.sender, sender)) {
234
+ matched.push({
235
+ priority: item.priority,
236
+ handler: item.handler,
237
+ isAny: false,
238
+ remove: () => {
239
+ this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
240
+ },
241
+ once: item.once,
242
+ });
243
+ if (item.once) {
244
+ hasOnce = true;
245
+ }
246
+ }
247
+ }
248
+ if (hasOnce) {
249
+ this.wildcardListeners = this.wildcardListeners.filter((l) => !l.once);
250
+ }
251
+ }
252
+
253
+ // 3. Any listeners
254
+ if (this.anyListeners.length > 0) {
255
+ let hasOnce = false;
256
+ for (let i = 0; i < this.anyListeners.length; i++) {
257
+ const item = this.anyListeners[i];
221
258
  matched.push({
222
259
  priority: item.priority,
223
260
  handler: item.handler,
224
- isAny: false,
261
+ isAny: true,
225
262
  remove: () => {
226
- this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
263
+ this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
227
264
  },
228
265
  once: item.once,
229
266
  });
267
+ if (item.once) {
268
+ hasOnce = true;
269
+ }
230
270
  }
231
- if (!item.once) {
232
- remainingWildcards.push(item);
233
- }
234
- }
235
- this.wildcardListeners = remainingWildcards;
236
-
237
- // 3. Any listeners
238
- const remainingAny = [];
239
- for (const item of this.anyListeners) {
240
- matched.push({
241
- priority: item.priority,
242
- handler: item.handler,
243
- isAny: true,
244
- remove: () => {
245
- this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
246
- },
247
- once: item.once,
248
- });
249
- if (!item.once) {
250
- remainingAny.push(item);
271
+ if (hasOnce) {
272
+ this.anyListeners = this.anyListeners.filter((l) => !l.once);
251
273
  }
252
274
  }
253
- this.anyListeners = remainingAny;
254
275
 
255
276
  // Sort by priority descending (higher numbers first)
256
- matched.sort((a, b) => b.priority - a.priority);
277
+ if (matched.length > 1) {
278
+ matched.sort((a, b) => b.priority - a.priority);
279
+ }
257
280
 
258
281
  return matched;
259
282
  }
@@ -4,15 +4,7 @@
4
4
  * @module core/forms/form-builder
5
5
  */
6
6
 
7
- function escapeHtml(str) {
8
- if (str === null || str === undefined) return '';
9
- return String(str)
10
- .replace(/&/g, '&amp;')
11
- .replace(/</g, '&lt;')
12
- .replace(/>/g, '&gt;')
13
- .replace(/"/g, '&quot;')
14
- .replace(/'/g, '&#039;');
15
- }
7
+ const { escapeHtml } = require('../utils/html');
16
8
 
17
9
  /**
18
10
  * Render a single form field with label, input, and validation error message
@@ -2,7 +2,7 @@
2
2
  * ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
3
3
  */
4
4
 
5
- const { getAllModels } = require('../orm/model');
5
+ const modelModule = require('../orm/model');
6
6
 
7
7
  /**
8
8
  * Generate OpenAPI 3 components.schemas from registered ORM models
@@ -10,8 +10,7 @@ const { getAllModels } = require('../orm/model');
10
10
  * @returns {Record<string, object>}
11
11
  */
12
12
  function generateOrmOpenApiSchemas(options = {}) {
13
- const { exclude = [] } = options;
14
- const models = getAllModels();
13
+ const { exclude = [], models = modelModule.getAllModels() } = options;
15
14
  const schemas = {};
16
15
 
17
16
  for (const [name, model] of models) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const { ModelEvents, Hooks } = require('../events');
8
- const { getModel } = require('../model');
8
+ const modelModule = require('../model');
9
9
 
10
10
  /** @type {Set<import('./layer').OrmCacheLayer>} */
11
11
  const layers = new Set();
@@ -27,7 +27,7 @@ function registerOrmCacheListeners(cacheLayer) {
27
27
  */
28
28
  function handleMutation(record, ctx, op) {
29
29
  const modelName = ctx.model;
30
- const model = getModel(modelName);
30
+ const model = modelModule.getModel(modelName);
31
31
  if (!model) return;
32
32
 
33
33
  for (const layer of layers) {
@@ -68,7 +68,17 @@ function unregisterOrmCacheListeners(cacheLayer) {
68
68
  }
69
69
  }
70
70
 
71
+ function _resetForTesting() {
72
+ for (const unsub of unsubs) {
73
+ if (typeof unsub === 'function') unsub();
74
+ }
75
+ unsubs = [];
76
+ layers.clear();
77
+ hooked = false;
78
+ }
79
+
71
80
  module.exports = {
72
81
  registerOrmCacheListeners,
73
82
  unregisterOrmCacheListeners,
83
+ _resetForTesting,
74
84
  };
package/core/orm/index.js CHANGED
@@ -392,4 +392,9 @@ module.exports = {
392
392
  // Schema Diff & Makemigrations (Django-inspired)
393
393
  diffSchema: require('./migrations/diff').diffSchema,
394
394
  makeMigrations: require('./migrations/diff').makeMigrations,
395
+ // Universal Query Filters (REST Resources & Admin Panel)
396
+ buildFilteredQuery: require('./query-filters').buildFilteredQuery,
397
+ applyColumnFilters: require('./query-filters').applyColumnFilters,
398
+ applyFilterCondition: require('./query-filters').applyFilterCondition,
399
+ queryFilters: require('./query-filters'),
395
400
  };
@@ -9,14 +9,20 @@
9
9
  * @returns {Set<string>} Set of JSON column names
10
10
  */
11
11
  function getJsonColumns(model) {
12
+ if (model && model._jsonColumns) {
13
+ return model._jsonColumns;
14
+ }
12
15
  const jsonCols = new Set();
13
- if (model.columns) {
16
+ if (model && model.columns) {
14
17
  for (const [name, meta] of model.columns) {
15
18
  if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
16
19
  jsonCols.add(name);
17
20
  }
18
21
  }
19
22
  }
23
+ if (model) {
24
+ model._jsonColumns = jsonCols;
25
+ }
20
26
  return jsonCols;
21
27
  }
22
28
 
package/core/orm/model.js CHANGED
@@ -4,9 +4,11 @@
4
4
  * @module core/orm/model
5
5
  */
6
6
 
7
+ const z = require('zod');
7
8
  const { trimUrlPathSlashes } = require('../url-path-normalize');
8
- const { extractColumnsFromSchema } = require('./schema-helpers');
9
+ const { createSchemaHelpers, extractColumnsFromSchema } = require('./schema-helpers');
9
10
  const { ModelEvents, Hooks } = require('./events');
11
+ const zdb = z ? createSchemaHelpers(z) : null;
10
12
 
11
13
  /**
12
14
  * Global model registry
@@ -34,6 +36,10 @@ function defineModel(options) {
34
36
  queryLimits = {},
35
37
  cache: cacheOption,
36
38
  searchable,
39
+ timestamps: timestampsOpt,
40
+ softDelete: softDeleteOpt,
41
+ softDeletes: softDeletesOpt,
42
+ options: nestedOptions = {},
37
43
  } = options;
38
44
 
39
45
  // Validate required fields
@@ -43,15 +49,94 @@ function defineModel(options) {
43
49
  if (!table || typeof table !== 'string') {
44
50
  throw new Error('Model table is required and must be a string');
45
51
  }
46
- if (!schema || typeof schema.parse !== 'function') {
52
+ if (!schema || typeof schema !== 'object' || (typeof schema.parse !== 'function' && Object.keys(schema).length === 0)) {
47
53
  throw new Error('Model schema is required and must be a Zod schema');
48
54
  }
49
55
 
50
- let normalizedSchema = schema;
51
- if (schema && schema.shape) {
52
- for (const [key, val] of Object.entries(schema.shape)) {
56
+ // Resolve timestamps and softDelete options (from top-level flags, nested options, or scopes)
57
+ const timestampsConfig = timestampsOpt !== undefined
58
+ ? timestampsOpt
59
+ : (nestedOptions.timestamps !== undefined ? nestedOptions.timestamps : scopes.timestamps);
60
+
61
+ const softDeleteConfig = softDeleteOpt !== undefined
62
+ ? softDeleteOpt
63
+ : (softDeletesOpt !== undefined
64
+ ? softDeletesOpt
65
+ : (nestedOptions.softDelete !== undefined
66
+ ? nestedOptions.softDelete
67
+ : (nestedOptions.softDeletes !== undefined
68
+ ? nestedOptions.softDeletes
69
+ : scopes.softDelete)));
70
+
71
+ const isPlainObject = typeof schema.parse !== 'function';
72
+ const injectedFields = {};
73
+
74
+ if (timestampsConfig) {
75
+ const isSnake = typeof timestampsConfig === 'object'
76
+ ? Boolean(timestampsConfig.snakeCase || timestampsConfig.format === 'snake')
77
+ : (timestampsConfig === 'snake');
78
+ const createdCol = (typeof timestampsConfig === 'object' && timestampsConfig.createdAt)
79
+ || (isSnake ? 'created_at' : 'createdAt');
80
+ const updatedCol = (typeof timestampsConfig === 'object' && timestampsConfig.updatedAt)
81
+ || (isSnake ? 'updated_at' : 'updatedAt');
82
+
83
+ const hasCreated = isPlainObject
84
+ ? (schema[createdCol] !== undefined || schema.created_at !== undefined || schema.createdAt !== undefined)
85
+ : (schema.shape && (schema.shape[createdCol] !== undefined || schema.shape.created_at !== undefined || schema.shape.createdAt !== undefined));
86
+ const hasUpdated = isPlainObject
87
+ ? (schema[updatedCol] !== undefined || schema.updated_at !== undefined || schema.updatedAt !== undefined)
88
+ : (schema.shape && (schema.shape[updatedCol] !== undefined || schema.shape.updated_at !== undefined || schema.shape.updatedAt !== undefined));
89
+
90
+ if (!hasCreated && zdb) {
91
+ injectedFields[createdCol] = zdb.timestamp({ auto: 'create' });
92
+ }
93
+ if (!hasUpdated && zdb) {
94
+ injectedFields[updatedCol] = zdb.timestamp({ auto: 'update' });
95
+ }
96
+ }
97
+
98
+ if (softDeleteConfig) {
99
+ const isSnake = typeof softDeleteConfig === 'object'
100
+ ? Boolean(softDeleteConfig.snakeCase || softDeleteConfig.format === 'snake')
101
+ : (softDeleteConfig === 'snake');
102
+ const deletedCol = (typeof softDeleteConfig === 'object' && softDeleteConfig.deletedAt)
103
+ || (isSnake ? 'deleted_at' : 'deletedAt');
104
+
105
+ const hasDeleted = isPlainObject
106
+ ? (schema[deletedCol] !== undefined || schema.deleted_at !== undefined || schema.deletedAt !== undefined)
107
+ : (schema.shape && (schema.shape[deletedCol] !== undefined || schema.shape.deleted_at !== undefined || schema.shape.deletedAt !== undefined));
108
+
109
+ if (!hasDeleted && zdb) {
110
+ injectedFields[deletedCol] = zdb.softDelete();
111
+ }
112
+ }
113
+
114
+ let normalizedSchema;
115
+ if (isPlainObject) {
116
+ if (!zdb) {
117
+ throw new Error('Zod is required to compile schema object');
118
+ }
119
+ normalizedSchema = zdb.schema({
120
+ ...schema,
121
+ ...injectedFields,
122
+ });
123
+ } else {
124
+ normalizedSchema = schema;
125
+ if (Object.keys(injectedFields).length > 0 && typeof normalizedSchema.extend === 'function') {
126
+ const finalizedInjected = {};
127
+ for (const [key, field] of Object.entries(injectedFields)) {
128
+ finalizedInjected[key] = (field && typeof field._finalize === 'function')
129
+ ? field._finalize()
130
+ : field;
131
+ }
132
+ normalizedSchema = normalizedSchema.extend(finalizedInjected);
133
+ }
134
+ }
135
+
136
+ if (normalizedSchema && normalizedSchema.shape) {
137
+ for (const [key, val] of Object.entries(normalizedSchema.shape)) {
53
138
  if (val && typeof val._finalize === 'function') {
54
- schema.shape[key] = val._finalize();
139
+ normalizedSchema.shape[key] = val._finalize();
55
140
  }
56
141
  }
57
142
  }
@@ -105,13 +190,19 @@ function defineModel(options) {
105
190
  name,
106
191
  table,
107
192
  schema: normalizedSchema,
193
+ _partialSchema: typeof normalizedSchema?.partial === 'function' ? normalizedSchema.partial() : normalizedSchema,
108
194
  primaryKey,
109
195
  relations: normalizedRelations,
110
196
  scopes: {
111
- softDelete: scopes.softDelete || false,
112
- timestamps: scopes.timestamps || false,
197
+ softDelete: Boolean(softDeleteConfig),
198
+ timestamps: Boolean(timestampsConfig),
113
199
  tenant: scopes.tenant || null,
114
200
  },
201
+ options: {
202
+ timestamps: timestampsConfig || false,
203
+ softDelete: softDeleteConfig || false,
204
+ ...nestedOptions,
205
+ },
115
206
  columns,
116
207
  admin: {
117
208
  enabled: admin.enabled === true, // Explicit boolean check
@@ -42,7 +42,7 @@ class QueryBuilder {
42
42
  constructor(model, knex, initialContext, cacheLayer = null) {
43
43
  this.model = model;
44
44
  this.knex = knex;
45
- this.scopeContext = initialContext || createScopeContext();
45
+ this.scopeContext = initialContext ? { ...initialContext } : createScopeContext();
46
46
  this.cacheLayer = cacheLayer;
47
47
  this.jsonColumns = getJsonColumns(model);
48
48
 
@@ -78,11 +78,11 @@ class QueryBuilder {
78
78
  where(columnOrConditions, operatorOrValue, value) {
79
79
  // Object form: where({ column: value, ... })
80
80
  if (typeof columnOrConditions === 'object' && columnOrConditions !== null) {
81
- for (const [col, val] of Object.entries(columnOrConditions)) {
81
+ for (const col of Object.keys(columnOrConditions)) {
82
82
  this.state.wheres.push({
83
83
  column: col,
84
84
  operator: '=',
85
- value: val,
85
+ value: columnOrConditions[col],
86
86
  boolean: 'and',
87
87
  });
88
88
  }
@@ -176,6 +176,22 @@ class QueryBuilder {
176
176
  return this;
177
177
  }
178
178
 
179
+ /**
180
+ * Add a WHERE NOT clause (col != val or multiple columns)
181
+ * @param {string|Object} column - Column name or object of { col: val }
182
+ * @param {*} [value] - Value to compare against
183
+ * @returns {this}
184
+ */
185
+ whereNot(column, value) {
186
+ if (typeof column === 'object' && column !== null) {
187
+ for (const [col, val] of Object.entries(column)) {
188
+ this.where(col, '!=', val);
189
+ }
190
+ return this;
191
+ }
192
+ return this.where(column, '!=', value);
193
+ }
194
+
179
195
  /**
180
196
  * Add a WHERE NOT IN clause
181
197
  * @param {string} column - Column name
@@ -477,27 +493,37 @@ class QueryBuilder {
477
493
  * @returns {Promise<Object|null>}
478
494
  */
479
495
  async first() {
480
- const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
496
+ const shouldEmit = (
497
+ ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
498
+ ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
499
+ );
500
+ const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
481
501
  const self = this;
482
502
 
483
503
  async function loadFromDb() {
484
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
485
- if (ctx.isCancelled) {
486
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
504
+ if (shouldEmit) {
505
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
506
+ if (ctx.isCancelled) {
507
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
508
+ }
487
509
  }
488
510
 
489
511
  const qb = self.toKnex().first();
490
512
  const result = await qb;
491
513
  if (!result) return null;
492
514
 
493
- deserializeJsonFields(result, self.jsonColumns);
515
+ if (self.jsonColumns.size > 0) {
516
+ deserializeJsonFields(result, self.jsonColumns);
517
+ }
494
518
 
495
519
  const withs = self.getWiths();
496
520
  if (withs.length > 0) {
497
521
  await loadRelations([result], ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
498
522
  }
499
523
 
500
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
524
+ if (shouldEmit) {
525
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
526
+ }
501
527
  return result;
502
528
  }
503
529
 
@@ -543,19 +569,27 @@ class QueryBuilder {
543
569
  * @returns {Promise<Object[]>}
544
570
  */
545
571
  async list() {
546
- const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
572
+ const shouldEmit = (
573
+ ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
574
+ ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
575
+ );
576
+ const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
547
577
  const self = this;
548
578
 
549
579
  async function loadFromDb() {
550
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
551
- if (ctx.isCancelled) {
552
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
580
+ if (shouldEmit) {
581
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
582
+ if (ctx.isCancelled) {
583
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
584
+ }
553
585
  }
554
586
 
555
587
  const results = await self.toKnex();
556
588
 
557
- for (const record of results) {
558
- deserializeJsonFields(record, self.jsonColumns);
589
+ if (self.jsonColumns.size > 0) {
590
+ for (const record of results) {
591
+ deserializeJsonFields(record, self.jsonColumns);
592
+ }
559
593
  }
560
594
 
561
595
  const withs = self.getWiths();
@@ -563,8 +597,10 @@ class QueryBuilder {
563
597
  await loadRelations(results, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
564
598
  }
565
599
 
566
- for (const record of results) {
567
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
600
+ if (shouldEmit) {
601
+ for (const record of results) {
602
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
603
+ }
568
604
  }
569
605
  return results;
570
606
  }
@@ -676,13 +712,19 @@ class QueryBuilder {
676
712
  perPage = validated.perPage;
677
713
  page = Math.max(1, parseInt(page, 10) || 1);
678
714
 
679
- const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
715
+ const shouldEmit = (
716
+ ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
717
+ ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
718
+ );
719
+ const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
680
720
  const self = this;
681
721
 
682
722
  async function loadFromDb() {
683
- await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
684
- if (ctx.isCancelled) {
685
- throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
723
+ if (shouldEmit) {
724
+ await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
725
+ if (ctx.isCancelled) {
726
+ throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
727
+ }
686
728
  }
687
729
 
688
730
  const base = self.toKnex({ includeLimitOffset: false });
@@ -693,8 +735,10 @@ class QueryBuilder {
693
735
  const offset = (page - 1) * perPage;
694
736
  const data = await base.clone().limit(perPage).offset(offset);
695
737
 
696
- for (const record of data) {
697
- deserializeJsonFields(record, self.jsonColumns);
738
+ if (self.jsonColumns.size > 0) {
739
+ for (const record of data) {
740
+ deserializeJsonFields(record, self.jsonColumns);
741
+ }
698
742
  }
699
743
 
700
744
  const withs = self.getWiths();
@@ -702,8 +746,10 @@ class QueryBuilder {
702
746
  await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
703
747
  }
704
748
 
705
- for (const record of data) {
706
- ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
749
+ if (shouldEmit) {
750
+ for (const record of data) {
751
+ ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
752
+ }
707
753
  }
708
754
 
709
755
  return {