webspresso 0.0.90 → 0.0.93

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 (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -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
+ };
@@ -49,6 +49,35 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
49
49
  return records;
50
50
  }
51
51
 
52
+ /**
53
+ * Execute whereIn query in chunks to prevent parameter limits and query planner degradation
54
+ * @param {import('knex').Knex} knex
55
+ * @param {string} table
56
+ * @param {string} column
57
+ * @param {Array<string|number>} values
58
+ * @param {import('./types').ScopeContext} scopeContext
59
+ * @param {import('./types').ModelDefinition} model
60
+ * @param {number} [chunkSize=500]
61
+ * @returns {Promise<Object[]>}
62
+ */
63
+ async function queryInChunks(knex, table, column, values, scopeContext, model, chunkSize = 500) {
64
+ if (values.length <= chunkSize) {
65
+ let qb = knex(table).whereIn(column, values);
66
+ qb = applyScopes(qb, scopeContext, model);
67
+ return await qb;
68
+ }
69
+
70
+ const results = [];
71
+ for (let i = 0; i < values.length; i += chunkSize) {
72
+ const slice = values.slice(i, i + chunkSize);
73
+ let qb = knex(table).whereIn(column, slice);
74
+ qb = applyScopes(qb, scopeContext, model);
75
+ const chunkResults = await qb;
76
+ results.push(...chunkResults);
77
+ }
78
+ return results;
79
+ }
80
+
52
81
  /**
53
82
  * Load belongsTo relation
54
83
  * Parent record has foreign key pointing to related record's primary key
@@ -78,9 +107,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
78
107
  }
79
108
 
80
109
  const foreignKeyValues = Array.from(fkSet);
81
- let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
82
- qb = applyScopes(qb, scopeContext, relatedModel);
83
- const relatedRecords = await qb;
110
+ const relatedRecords = await queryInChunks(
111
+ knex,
112
+ relatedModel.table,
113
+ relatedModel.primaryKey,
114
+ foreignKeyValues,
115
+ scopeContext,
116
+ relatedModel
117
+ );
84
118
 
85
119
  const relatedMap = new Map();
86
120
  const rLen = relatedRecords.length;
@@ -128,9 +162,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
128
162
  }
129
163
 
130
164
  const primaryKeyValues = Array.from(pkSet);
131
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
132
- qb = applyScopes(qb, scopeContext, relatedModel);
133
- const relatedRecords = await qb;
165
+ const relatedRecords = await queryInChunks(
166
+ knex,
167
+ relatedModel.table,
168
+ foreignKey,
169
+ primaryKeyValues,
170
+ scopeContext,
171
+ relatedModel
172
+ );
134
173
 
135
174
  const relatedGroups = new Map();
136
175
  const rLen = relatedRecords.length;
@@ -181,9 +220,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
181
220
  }
182
221
 
183
222
  const primaryKeyValues = Array.from(pkSet);
184
- let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
185
- qb = applyScopes(qb, scopeContext, relatedModel);
186
- const relatedRecords = await qb;
223
+ const relatedRecords = await queryInChunks(
224
+ knex,
225
+ relatedModel.table,
226
+ foreignKey,
227
+ primaryKeyValues,
228
+ scopeContext,
229
+ relatedModel
230
+ );
187
231
 
188
232
  const relatedMap = new Map();
189
233
  const rLen = relatedRecords.length;
@@ -222,5 +266,6 @@ module.exports = {
222
266
  loadBelongsTo,
223
267
  loadHasMany,
224
268
  loadHasOne,
269
+ queryInChunks,
225
270
  };
226
271
 
@@ -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
@@ -66,10 +74,46 @@ function createDatabase(config) {
66
74
  }
67
75
  }
68
76
 
69
- // Create Knex instance
77
+ // Create Knex instance (default useNullAsDefault: true for SQLite to prevent unneeded warnings)
78
+ const isSqlite = client === 'better-sqlite3' || client === 'sqlite3';
79
+ const knexConfig = {
80
+ ...(isSqlite && config.useNullAsDefault === undefined ? { useNullAsDefault: true } : {}),
81
+ ...config,
82
+ };
83
+
84
+ if (isSqlite) {
85
+ const userAfterCreate = knexConfig.pool?.afterCreate;
86
+ knexConfig.pool = {
87
+ ...(knexConfig.pool || {}),
88
+ afterCreate: (conn, cb) => {
89
+ try {
90
+ if (typeof conn.pragma === 'function') {
91
+ const isMemory =
92
+ knexConfig.connection === ':memory:' ||
93
+ knexConfig.connection?.filename === ':memory:' ||
94
+ knexConfig.connection?.database === ':memory:';
95
+
96
+ if (!isMemory) {
97
+ try { conn.pragma('journal_mode = WAL'); } catch (e) {}
98
+ }
99
+ try { conn.pragma('synchronous = NORMAL'); } catch (e) {}
100
+ try { conn.pragma('busy_timeout = 5000'); } catch (e) {}
101
+ try { conn.pragma('foreign_keys = ON'); } catch (e) {}
102
+ }
103
+ } catch (e) {}
104
+
105
+ if (typeof userAfterCreate === 'function') {
106
+ userAfterCreate(conn, cb);
107
+ } else {
108
+ cb(null, conn);
109
+ }
110
+ },
111
+ };
112
+ }
113
+
70
114
  let knexInstance;
71
115
  try {
72
- knexInstance = knex(config);
116
+ knexInstance = knex(knexConfig);
73
117
  } catch (e) {
74
118
  // Provide helpful error message
75
119
  if (e.message && (e.message.includes('Cannot find module') || e.message.includes('npm install'))) {
@@ -128,10 +172,14 @@ function createDatabase(config) {
128
172
 
129
173
  /**
130
174
  * Get a model by name
131
- * @param {string} name - Model name
175
+ * @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
132
176
  * @returns {import('./types').ModelDefinition}
133
177
  */
134
- function getModelInstance(name) {
178
+ function getModelInstance(nameOrModel) {
179
+ const name = typeof nameOrModel === 'object' && nameOrModel !== null && nameOrModel.name
180
+ ? nameOrModel.name
181
+ : nameOrModel;
182
+
135
183
  // First check local registry
136
184
  let model = models.get(name);
137
185
 
@@ -221,26 +269,34 @@ function createDatabase(config) {
221
269
  }
222
270
 
223
271
  /**
224
- * Run operations in a transaction
272
+ * Run operations in an ambient transaction
225
273
  * @param {Function} callback - Callback receiving transaction context
274
+ * @param {import('./types').ScopeContext} [scopeContext] - Optional scope context
226
275
  * @returns {Promise<*>} Result of callback
227
276
  */
228
- async function transaction(callback) {
277
+ async function transaction(callback, scopeContext) {
229
278
  return knexInstance.transaction(async (trx) => {
230
- // Create transaction context with repository methods
231
279
  const trxContext = {
232
280
  trx,
233
- getRepository(modelName, scopeContext) {
281
+ getRepository(modelName, sContext) {
234
282
  const model = getModelInstance(modelName);
235
- const ctx = scopeContext || createScopeContext();
283
+ const ctx = sContext || scopeContext || createScopeContext();
236
284
  return createRepository(model, trx, ctx, ormCacheLayer);
237
285
  },
238
- createRepository(model, scopeContext) {
239
- const ctx = scopeContext || createScopeContext();
286
+ createRepository(model, sContext) {
287
+ const ctx = sContext || scopeContext || createScopeContext();
240
288
  return createRepository(model, trx, ctx, ormCacheLayer);
241
289
  },
290
+ forTenant(tenantId) {
291
+ if (!scopeContext) scopeContext = createScopeContext();
292
+ scopeContext.tenantId = tenantId;
293
+ return this;
294
+ },
295
+ getScopeContext() {
296
+ return { ...(scopeContext || createScopeContext()) };
297
+ },
242
298
  };
243
- return callback(trxContext);
299
+ return runWithAmbientTransaction(trx, () => callback(trxContext));
244
300
  });
245
301
  }
246
302
 
@@ -255,6 +311,9 @@ function createDatabase(config) {
255
311
  createRepository: createRepositoryFromModel,
256
312
  query,
257
313
  transaction,
314
+ runInTransaction: transaction,
315
+ getAmbientTransaction,
316
+ hasActiveTransaction: hasAmbientTransaction,
258
317
  createSeeder: createSeederInstance,
259
318
  cache: ormCachePublicApi,
260
319
  destroy: () => {
@@ -275,6 +334,13 @@ module.exports = {
275
334
  createMemoryCacheProvider: require('./cache/memory-provider').createMemoryCacheProvider,
276
335
  OrmCacheLayer: require('./cache/layer').OrmCacheLayer,
277
336
  createOrmCacheFromConfig: require('./cache').createOrmCacheFromConfig,
337
+ // Ambient transaction management
338
+ ambientTransactionStorage,
339
+ runWithAmbientTransaction,
340
+ getAmbientTransaction,
341
+ hasAmbientTransaction,
342
+ createTransactionContext,
343
+ runTransaction,
278
344
  // Schema helpers
279
345
  zdb,
280
346
  createSchemaHelpers,
@@ -298,4 +364,9 @@ module.exports = {
298
364
  Hooks,
299
365
  HookCancellationError,
300
366
  createEventContext,
367
+ // Query Complexity & DoS Protection
368
+ QueryComplexityError: require('./complexity').QueryComplexityError,
369
+ validateQueryComplexity: require('./complexity').validateQueryComplexity,
370
+ resolveQueryLimits: require('./complexity').resolveQueryLimits,
371
+ DEFAULT_QUERY_LIMITS: require('./complexity').DEFAULT_QUERY_LIMITS,
301
372
  };
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
 
@@ -45,13 +46,22 @@ function defineModel(options) {
45
46
  throw new Error('Model schema is required and must be a Zod schema');
46
47
  }
47
48
 
49
+ let normalizedSchema = schema;
50
+ if (schema && schema.shape) {
51
+ for (const [key, val] of Object.entries(schema.shape)) {
52
+ if (val && typeof val._finalize === 'function') {
53
+ schema.shape[key] = val._finalize();
54
+ }
55
+ }
56
+ }
57
+
48
58
  // Check for duplicate registration
49
59
  if (modelRegistry.has(name)) {
50
60
  throw new Error(`Model "${name}" is already defined`);
51
61
  }
52
62
 
53
63
  // Extract column metadata from schema
54
- const columns = extractColumnsFromSchema(schema);
64
+ const columns = extractColumnsFromSchema(normalizedSchema);
55
65
 
56
66
  // Validate relations
57
67
  const normalizedRelations = {};
@@ -79,7 +89,7 @@ function defineModel(options) {
79
89
  const model = {
80
90
  name,
81
91
  table,
82
- schema,
92
+ schema: normalizedSchema,
83
93
  primaryKey,
84
94
  relations: normalizedRelations,
85
95
  scopes: {
@@ -105,6 +115,7 @@ function defineModel(options) {
105
115
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
106
116
  },
107
117
  hidden: Array.isArray(hidden) ? hidden : [],
118
+ queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
108
119
  hooks: {},
109
120
  cache: cacheOption,
110
121
  };
@@ -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