webspresso 0.0.90 → 0.0.91

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 (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. package/src/services/validator.js +281 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Error Normalization & Formatting Utilities
3
+ * Standardizes errors across all layers and formats safe HTTP error payloads.
4
+ * @module core/errors/normalize
5
+ */
6
+
7
+ const { WebspressoError } = require('./base');
8
+ const { HttpError } = require('./http');
9
+
10
+ const HTTP_TITLES = {
11
+ 400: 'Bad Request',
12
+ 401: 'Unauthorized',
13
+ 403: 'Forbidden',
14
+ 404: 'Not Found',
15
+ 405: 'Method Not Allowed',
16
+ 408: 'Request Timeout',
17
+ 409: 'Conflict',
18
+ 413: 'Payload Too Large',
19
+ 415: 'Unsupported Media Type',
20
+ 422: 'Unprocessable Entity',
21
+ 429: 'Too Many Requests',
22
+ 500: 'Internal Server Error',
23
+ 502: 'Bad Gateway',
24
+ 503: 'Service Unavailable',
25
+ 504: 'Gateway Timeout',
26
+ };
27
+
28
+ /**
29
+ * Get human-readable title for an HTTP status code
30
+ * @param {number} status
31
+ * @returns {string}
32
+ */
33
+ function getStatusTitle(status) {
34
+ return HTTP_TITLES[status] || (status >= 500 ? 'Internal Server Error' : 'Error');
35
+ }
36
+
37
+ /**
38
+ * Normalize any thrown value (Error, string, object, null) into a standardized HttpError or WebspressoError
39
+ * @param {unknown} err - The raw thrown error
40
+ * @param {boolean} [isDev=false] - Whether in development environment
41
+ * @returns {HttpError|WebspressoError}
42
+ */
43
+ function normalizeError(err, isDev = false) {
44
+ // Handle nullish throws
45
+ if (err === null || err === undefined) {
46
+ return new HttpError(500, 'Internal Server Error', {
47
+ expose: false,
48
+ });
49
+ }
50
+
51
+ // If already a WebspressoError / HttpError
52
+ if (err instanceof WebspressoError) {
53
+ return err;
54
+ }
55
+
56
+ // If standard JavaScript Error (TypeError, RangeError, standard Error)
57
+ if (err instanceof Error) {
58
+ const status = typeof err.status === 'number'
59
+ ? err.status
60
+ : (typeof err.statusCode === 'number' ? err.statusCode : 500);
61
+
62
+ const isClientError = status < 500;
63
+ const expose = typeof err.expose === 'boolean' ? err.expose : isClientError;
64
+
65
+ const httpErr = new HttpError(
66
+ status,
67
+ (expose || isDev) ? (err.message || getStatusTitle(status)) : getStatusTitle(status),
68
+ {
69
+ code: err.code || (status === 500 ? 'INTERNAL_SERVER_ERROR' : undefined),
70
+ details: err.details,
71
+ cause: err,
72
+ headers: err.headers,
73
+ expose,
74
+ }
75
+ );
76
+
77
+ // Retain original stack
78
+ if (err.stack) {
79
+ httpErr.stack = err.stack;
80
+ }
81
+
82
+ return httpErr;
83
+ }
84
+
85
+ // If primitive string or number thrown
86
+ if (typeof err === 'string' || typeof err === 'number' || typeof err === 'boolean') {
87
+ const msg = String(err);
88
+ return new HttpError(500, isDev ? msg : 'Internal Server Error', {
89
+ details: { rawValue: err },
90
+ expose: false,
91
+ });
92
+ }
93
+
94
+ // If plain object thrown (e.g. { message: 'Failed', status: 400 })
95
+ if (typeof err === 'object') {
96
+ const status = typeof err.status === 'number'
97
+ ? err.status
98
+ : (typeof err.statusCode === 'number' ? err.statusCode : 500);
99
+
100
+ const msg = typeof err.message === 'string' ? err.message : getStatusTitle(status);
101
+
102
+ return new HttpError(status, (status < 500 || isDev) ? msg : getStatusTitle(status), {
103
+ code: err.code,
104
+ details: err.details,
105
+ headers: err.headers,
106
+ expose: typeof err.expose === 'boolean' ? err.expose : status < 500,
107
+ });
108
+ }
109
+
110
+ return new HttpError(500, 'Internal Server Error', { expose: false });
111
+ }
112
+
113
+ /**
114
+ * Format a normalized error into a clean JSON response object conforming to contract
115
+ * @param {HttpError|WebspressoError|Error} error - Normalized error
116
+ * @param {boolean} [isDev=false] - Whether running in development mode
117
+ * @returns {Record<string, unknown>}
118
+ */
119
+ function toErrorResponseObject(error, isDev = false) {
120
+ const status = typeof error.status === 'number' ? error.status : 500;
121
+ const errorTitle = error.name === 'ValidationError'
122
+ ? 'Validation Error'
123
+ : getStatusTitle(status);
124
+
125
+ let shouldExpose;
126
+ if (typeof error.expose === 'boolean') {
127
+ shouldExpose = error.expose || isDev;
128
+ } else {
129
+ shouldExpose = isDev || status < 500;
130
+ }
131
+
132
+ const message = shouldExpose && error.message ? error.message : getStatusTitle(status);
133
+
134
+ const payload = {
135
+ status,
136
+ error: errorTitle,
137
+ message,
138
+ };
139
+
140
+ if (error.code) {
141
+ payload.code = error.code;
142
+ }
143
+
144
+ // Include validation fields if present
145
+ if (error.fields && Object.keys(error.fields).length > 0 && shouldExpose) {
146
+ payload.fields = error.fields;
147
+ }
148
+
149
+ // Include contextual details if expose is true or in dev
150
+ if (error.details !== undefined && shouldExpose) {
151
+ payload.details = error.details;
152
+ }
153
+
154
+ // Include stack and cause strictly in development mode
155
+ if (isDev) {
156
+ if (error.stack) {
157
+ payload.stack = error.stack;
158
+ }
159
+ if (error.cause) {
160
+ payload.cause = error.cause instanceof Error ? (error.cause.stack || error.cause.message) : error.cause;
161
+ }
162
+ }
163
+
164
+ return payload;
165
+ }
166
+
167
+ module.exports = {
168
+ getStatusTitle,
169
+ normalizeError,
170
+ toErrorResponseObject,
171
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ORM Query Complexity & DoS Protection
3
+ * @module core/orm/complexity
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const { BadRequestError } = require('../errors/http');
9
+
10
+ class QueryComplexityError extends BadRequestError {
11
+ /**
12
+ * @param {string} message
13
+ * @param {Object} [details]
14
+ */
15
+ constructor(message, details = {}) {
16
+ super(message || 'Query complexity limit exceeded', details);
17
+ this.name = 'QueryComplexityError';
18
+ this.code = 'QUERY_COMPLEXITY_EXCEEDED';
19
+ }
20
+ }
21
+
22
+ const DEFAULT_QUERY_LIMITS = Object.freeze({
23
+ maxLimit: 100,
24
+ defaultLimit: 15,
25
+ maxIncludes: 5,
26
+ maxFilterConditions: 25,
27
+ });
28
+
29
+ /**
30
+ * Resolve query limits for a given model
31
+ * @param {import('./types').ModelDefinition} [model]
32
+ * @returns {typeof DEFAULT_QUERY_LIMITS}
33
+ */
34
+ function resolveQueryLimits(model) {
35
+ if (!model || !model.queryLimits) {
36
+ return DEFAULT_QUERY_LIMITS;
37
+ }
38
+ return {
39
+ maxLimit: Number(model.queryLimits.maxLimit) || DEFAULT_QUERY_LIMITS.maxLimit,
40
+ defaultLimit: Number(model.queryLimits.defaultLimit) || DEFAULT_QUERY_LIMITS.defaultLimit,
41
+ maxIncludes: Number(model.queryLimits.maxIncludes) || DEFAULT_QUERY_LIMITS.maxIncludes,
42
+ maxFilterConditions: Number(model.queryLimits.maxFilterConditions) || DEFAULT_QUERY_LIMITS.maxFilterConditions,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Validate and clamp query complexity parameters against model / default limits
48
+ * @param {import('./types').ModelDefinition} [model]
49
+ * @param {Object} params
50
+ * @param {number} [params.perPage]
51
+ * @param {number} [params.limit]
52
+ * @param {string[]} [params.includes]
53
+ * @param {number} [params.filterCount]
54
+ * @param {boolean} [params.strict=false] - If true, throws QueryComplexityError instead of clamping
55
+ * @returns {{ limit: number, perPage: number, includes: string[] }}
56
+ */
57
+ function validateQueryComplexity(model, params = {}) {
58
+ const limits = resolveQueryLimits(model);
59
+ const strict = params.strict === true;
60
+
61
+ // 1. Validate includes
62
+ let includes = Array.isArray(params.includes) ? params.includes : [];
63
+ if (includes.length > limits.maxIncludes) {
64
+ if (strict) {
65
+ throw new QueryComplexityError(
66
+ `Too many eager-loaded relations (${includes.length}). Maximum allowed is ${limits.maxIncludes}.`,
67
+ { maxIncludes: limits.maxIncludes, requested: includes.length }
68
+ );
69
+ }
70
+ includes = includes.slice(0, limits.maxIncludes);
71
+ }
72
+
73
+ // 2. Validate perPage / limit
74
+ let requestedLimit = params.perPage !== undefined ? params.perPage : params.limit;
75
+ if (requestedLimit !== undefined) {
76
+ const numLimit = parseInt(requestedLimit, 10);
77
+ if (!isNaN(numLimit)) {
78
+ if (numLimit > limits.maxLimit) {
79
+ if (strict) {
80
+ throw new QueryComplexityError(
81
+ `Requested limit (${numLimit}) exceeds maximum allowed limit of ${limits.maxLimit}.`,
82
+ { maxLimit: limits.maxLimit, requested: numLimit }
83
+ );
84
+ }
85
+ requestedLimit = limits.maxLimit;
86
+ } else if (numLimit < 1) {
87
+ requestedLimit = limits.defaultLimit;
88
+ } else {
89
+ requestedLimit = numLimit;
90
+ }
91
+ } else {
92
+ requestedLimit = limits.defaultLimit;
93
+ }
94
+ } else {
95
+ requestedLimit = limits.defaultLimit;
96
+ }
97
+
98
+ // 3. Validate filter count
99
+ if (params.filterCount !== undefined && params.filterCount > limits.maxFilterConditions) {
100
+ if (strict) {
101
+ throw new QueryComplexityError(
102
+ `Too many filter conditions (${params.filterCount}). Maximum allowed is ${limits.maxFilterConditions}.`,
103
+ { maxFilterConditions: limits.maxFilterConditions, requested: params.filterCount }
104
+ );
105
+ }
106
+ }
107
+
108
+ return {
109
+ limit: requestedLimit,
110
+ perPage: requestedLimit,
111
+ includes,
112
+ };
113
+ }
114
+
115
+ module.exports = {
116
+ QueryComplexityError,
117
+ DEFAULT_QUERY_LIMITS,
118
+ resolveQueryLimits,
119
+ validateQueryComplexity,
120
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Webspresso ORM - Events/Signals System
3
- * Django-style signal system for model lifecycle events
3
+ * Django-style signal system for model lifecycle events with leak detection and maxListeners bounds
4
4
  * @module core/orm/events
5
5
  */
6
6
 
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  const EMPTY_ARRAY = Object.freeze([]);
19
+ const DEFAULT_MAX_LISTENERS = 50;
19
20
 
20
21
  function cancelContext(reason = 'Operation cancelled') {
21
22
  this.isCancelled = true;
@@ -42,12 +43,34 @@ function createEventContext(model, operation, trx = null) {
42
43
 
43
44
  /**
44
45
  * ModelEvents - Global event bus for ORM lifecycle events
45
- * Singleton class that manages event listeners and emission
46
+ * Singleton class that manages event listeners, emission, and memory leak warnings
46
47
  */
47
48
  class ModelEventsClass {
48
49
  constructor() {
49
50
  /** @type {Map<string, Set<Function>>} */
50
51
  this.listeners = new Map();
52
+ this.maxListeners = DEFAULT_MAX_LISTENERS;
53
+ }
54
+
55
+ /**
56
+ * Set maximum number of listeners allowed per event before warning
57
+ * @param {number} n
58
+ * @returns {this}
59
+ */
60
+ setMaxListeners(n) {
61
+ if (typeof n !== 'number' || n < 0 || Number.isNaN(n)) {
62
+ throw new TypeError('The value of "n" must be a non-negative number');
63
+ }
64
+ this.maxListeners = n;
65
+ return this;
66
+ }
67
+
68
+ /**
69
+ * Get current maximum listeners threshold
70
+ * @returns {number}
71
+ */
72
+ getMaxListeners() {
73
+ return this.maxListeners;
51
74
  }
52
75
 
53
76
  /**
@@ -64,7 +87,19 @@ class ModelEventsClass {
64
87
  if (!this.listeners.has(event)) {
65
88
  this.listeners.set(event, new Set());
66
89
  }
67
- this.listeners.get(event).add(callback);
90
+
91
+ const set = this.listeners.get(event);
92
+ set.add(callback);
93
+
94
+ // Memory leak detection & warning
95
+ if (this.maxListeners > 0 && set.size > this.maxListeners) {
96
+ const warnMsg = `Possible ModelEvents memory leak detected. ${set.size} '${event}' listeners added. Use setMaxListeners() to increase limit.`;
97
+ if (typeof process !== 'undefined' && typeof process.emitWarning === 'function') {
98
+ process.emitWarning(warnMsg, 'MaxListenersExceededWarning');
99
+ } else {
100
+ console.warn(`[webspresso:warning] ${warnMsg}`);
101
+ }
102
+ }
68
103
 
69
104
  // Return unsubscribe function
70
105
  return () => this.off(event, callback);
@@ -84,6 +119,27 @@ class ModelEventsClass {
84
119
  return this.on(event, wrapper);
85
120
  }
86
121
 
122
+ /**
123
+ * Attach a listener scoped to an HTTP request or lifecycle target (auto-removes on finish/close)
124
+ * @param {string} event - Event name
125
+ * @param {Function} callback - Callback function
126
+ * @param {Object} [lifecycleTarget] - Optional target with on/once (e.g. Express `res` or `req`)
127
+ * @returns {Function} Unsubscribe function
128
+ */
129
+ listenScoped(event, callback, lifecycleTarget) {
130
+ const unsubscribe = this.on(event, callback);
131
+
132
+ if (lifecycleTarget && typeof lifecycleTarget.once === 'function') {
133
+ const cleanup = () => {
134
+ unsubscribe();
135
+ };
136
+ lifecycleTarget.once('finish', cleanup);
137
+ lifecycleTarget.once('close', cleanup);
138
+ }
139
+
140
+ return unsubscribe;
141
+ }
142
+
87
143
  /**
88
144
  * Remove an event listener
89
145
  * @param {string} event - Event name
@@ -93,7 +149,11 @@ class ModelEventsClass {
93
149
  off(event, callback) {
94
150
  const listeners = this.listeners.get(event);
95
151
  if (listeners) {
96
- return listeners.delete(callback);
152
+ const removed = listeners.delete(callback);
153
+ if (listeners.size === 0) {
154
+ this.listeners.delete(event);
155
+ }
156
+ return removed;
97
157
  }
98
158
  return false;
99
159
  }
@@ -227,16 +287,6 @@ class ModelEventsClass {
227
287
  return ctx;
228
288
  }
229
289
 
230
- /**
231
- * Check if there are any listeners for an event
232
- * @param {string} model - Model name
233
- * @param {string} hook - Hook name
234
- * @returns {boolean}
235
- */
236
- hasListeners(model, hook) {
237
- return this.getMatchingListeners(model, hook).length > 0;
238
- }
239
-
240
290
  /**
241
291
  * Get listener count for an event
242
292
  * @param {string} [event] - Specific event (optional)
package/core/orm/index.js CHANGED
@@ -16,6 +16,14 @@ const { ModelEvents, Hooks, HookCancellationError, createEventContext } = requir
16
16
  const { omitHiddenColumns, sanitizeForOutput } = require('./utils');
17
17
  const { generateNanoid, zodNanoid, extendZ } = require('./utils/nanoid');
18
18
  const { createOrmCacheFromConfig, unregisterOrmCacheListeners } = require('./cache');
19
+ const {
20
+ ambientTransactionStorage,
21
+ runWithAmbientTransaction,
22
+ getAmbientTransaction,
23
+ hasAmbientTransaction,
24
+ createTransactionContext,
25
+ runTransaction,
26
+ } = require('./transaction');
19
27
 
20
28
  /**
21
29
  * Create a database instance
@@ -128,10 +136,14 @@ function createDatabase(config) {
128
136
 
129
137
  /**
130
138
  * Get a model by name
131
- * @param {string} name - Model name
139
+ * @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
132
140
  * @returns {import('./types').ModelDefinition}
133
141
  */
134
- function getModelInstance(name) {
142
+ function getModelInstance(nameOrModel) {
143
+ const name = typeof nameOrModel === 'object' && nameOrModel !== null && nameOrModel.name
144
+ ? nameOrModel.name
145
+ : nameOrModel;
146
+
135
147
  // First check local registry
136
148
  let model = models.get(name);
137
149
 
@@ -221,26 +233,34 @@ function createDatabase(config) {
221
233
  }
222
234
 
223
235
  /**
224
- * Run operations in a transaction
236
+ * Run operations in an ambient transaction
225
237
  * @param {Function} callback - Callback receiving transaction context
238
+ * @param {import('./types').ScopeContext} [scopeContext] - Optional scope context
226
239
  * @returns {Promise<*>} Result of callback
227
240
  */
228
- async function transaction(callback) {
241
+ async function transaction(callback, scopeContext) {
229
242
  return knexInstance.transaction(async (trx) => {
230
- // Create transaction context with repository methods
231
243
  const trxContext = {
232
244
  trx,
233
- getRepository(modelName, scopeContext) {
245
+ getRepository(modelName, sContext) {
234
246
  const model = getModelInstance(modelName);
235
- const ctx = scopeContext || createScopeContext();
247
+ const ctx = sContext || scopeContext || createScopeContext();
236
248
  return createRepository(model, trx, ctx, ormCacheLayer);
237
249
  },
238
- createRepository(model, scopeContext) {
239
- const ctx = scopeContext || createScopeContext();
250
+ createRepository(model, sContext) {
251
+ const ctx = sContext || scopeContext || createScopeContext();
240
252
  return createRepository(model, trx, ctx, ormCacheLayer);
241
253
  },
254
+ forTenant(tenantId) {
255
+ if (!scopeContext) scopeContext = createScopeContext();
256
+ scopeContext.tenantId = tenantId;
257
+ return this;
258
+ },
259
+ getScopeContext() {
260
+ return { ...(scopeContext || createScopeContext()) };
261
+ },
242
262
  };
243
- return callback(trxContext);
263
+ return runWithAmbientTransaction(trx, () => callback(trxContext));
244
264
  });
245
265
  }
246
266
 
@@ -255,6 +275,9 @@ function createDatabase(config) {
255
275
  createRepository: createRepositoryFromModel,
256
276
  query,
257
277
  transaction,
278
+ runInTransaction: transaction,
279
+ getAmbientTransaction,
280
+ hasActiveTransaction: hasAmbientTransaction,
258
281
  createSeeder: createSeederInstance,
259
282
  cache: ormCachePublicApi,
260
283
  destroy: () => {
@@ -275,6 +298,13 @@ module.exports = {
275
298
  createMemoryCacheProvider: require('./cache/memory-provider').createMemoryCacheProvider,
276
299
  OrmCacheLayer: require('./cache/layer').OrmCacheLayer,
277
300
  createOrmCacheFromConfig: require('./cache').createOrmCacheFromConfig,
301
+ // Ambient transaction management
302
+ ambientTransactionStorage,
303
+ runWithAmbientTransaction,
304
+ getAmbientTransaction,
305
+ hasAmbientTransaction,
306
+ createTransactionContext,
307
+ runTransaction,
278
308
  // Schema helpers
279
309
  zdb,
280
310
  createSchemaHelpers,
@@ -298,4 +328,9 @@ module.exports = {
298
328
  Hooks,
299
329
  HookCancellationError,
300
330
  createEventContext,
331
+ // Query Complexity & DoS Protection
332
+ QueryComplexityError: require('./complexity').QueryComplexityError,
333
+ validateQueryComplexity: require('./complexity').validateQueryComplexity,
334
+ resolveQueryLimits: require('./complexity').resolveQueryLimits,
335
+ DEFAULT_QUERY_LIMITS: require('./complexity').DEFAULT_QUERY_LIMITS,
301
336
  };
package/core/orm/model.js CHANGED
@@ -31,6 +31,7 @@ function defineModel(options) {
31
31
  rest = {},
32
32
  hooks = {},
33
33
  hidden = [],
34
+ queryLimits = {},
34
35
  cache: cacheOption,
35
36
  } = options;
36
37
 
@@ -105,6 +106,7 @@ function defineModel(options) {
105
106
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
106
107
  },
107
108
  hidden: Array.isArray(hidden) ? hidden : [],
109
+ queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
108
110
  hooks: {},
109
111
  cache: cacheOption,
110
112
  };
@@ -13,6 +13,8 @@ const {
13
13
  serializeJsonFields,
14
14
  deserializeJsonFields,
15
15
  } = require('./json-fields');
16
+ const { getAmbientTransaction } = require('./transaction');
17
+ const { validateQueryComplexity } = require('./complexity');
16
18
 
17
19
  /**
18
20
  * Create a new query builder
@@ -236,7 +238,8 @@ class QueryBuilder {
236
238
  * @returns {this}
237
239
  */
238
240
  limit(limit) {
239
- this.state.limitValue = limit;
241
+ const validated = validateQueryComplexity(this.model, { limit });
242
+ this.state.limitValue = validated.limit;
240
243
  return this;
241
244
  }
242
245
 
@@ -256,7 +259,9 @@ class QueryBuilder {
256
259
  * @returns {this}
257
260
  */
258
261
  with(...relations) {
259
- this.state.withs.push(...relations.flat());
262
+ const flat = relations.flat();
263
+ const validated = validateQueryComplexity(this.model, { includes: [...this.state.withs, ...flat] });
264
+ this.state.withs = validated.includes;
260
265
  return this;
261
266
  }
262
267
 
@@ -288,12 +293,28 @@ class QueryBuilder {
288
293
  return this;
289
294
  }
290
295
 
296
+ /**
297
+ * Resolve active Knex instance (explicit or ambient transaction)
298
+ * @returns {import('knex').Knex|import('knex').Knex.Transaction}
299
+ */
300
+ getActiveKnex() {
301
+ if (this.knex && (this.knex.isTransaction || typeof this.knex.commit === 'function')) {
302
+ return this.knex;
303
+ }
304
+ const ambientTrx = getAmbientTransaction();
305
+ if (ambientTrx) {
306
+ return ambientTrx;
307
+ }
308
+ return this.knex;
309
+ }
310
+
291
311
  /**
292
312
  * Transaction handle for lifecycle hooks (matches repository pattern)
293
313
  * @returns {import('knex').Knex.Transaction|null}
294
314
  */
295
315
  _hookTrx() {
296
- return this.knex.isTransaction ? this.knex : null;
316
+ const active = this.getActiveKnex();
317
+ return (active && (active.isTransaction || typeof active.commit === 'function')) ? active : null;
297
318
  }
298
319
 
299
320
  /**
@@ -304,7 +325,8 @@ class QueryBuilder {
304
325
  */
305
326
  toKnex(options = {}) {
306
327
  const { includeLimitOffset = true } = options;
307
- let qb = this.knex(this.model.table);
328
+ const activeKnex = this.getActiveKnex();
329
+ let qb = activeKnex(this.model.table);
308
330
 
309
331
  // Apply global scopes
310
332
  qb = applyScopes(qb, this.scopeContext, this.model);
@@ -564,6 +586,10 @@ class QueryBuilder {
564
586
  * @returns {Promise<import('./types').PaginatedResult>}
565
587
  */
566
588
  async paginate(page = 1, perPage = 15) {
589
+ const validated = validateQueryComplexity(this.model, { perPage });
590
+ perPage = validated.perPage;
591
+ page = Math.max(1, parseInt(page, 10) || 1);
592
+
567
593
  const ctx = createEventContext(this.model.name, 'find', this._hookTrx());
568
594
  const self = this;
569
595