webspresso 0.0.89 → 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 (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -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
 
@@ -54,6 +55,7 @@ function defineModel(options) {
54
55
  const columns = extractColumnsFromSchema(schema);
55
56
 
56
57
  // Validate relations
58
+ const normalizedRelations = {};
57
59
  for (const [relationName, relation] of Object.entries(relations)) {
58
60
  if (!['belongsTo', 'hasMany', 'hasOne'].includes(relation.type)) {
59
61
  throw new Error(
@@ -70,6 +72,8 @@ function defineModel(options) {
70
72
  `Relation "${relationName}" in model "${name}" must have a foreignKey string`
71
73
  );
72
74
  }
75
+
76
+ normalizedRelations[relationName] = relation;
73
77
  }
74
78
 
75
79
  // Create model definition
@@ -78,7 +82,7 @@ function defineModel(options) {
78
82
  table,
79
83
  schema,
80
84
  primaryKey,
81
- relations,
85
+ relations: normalizedRelations,
82
86
  scopes: {
83
87
  softDelete: scopes.softDelete || false,
84
88
  timestamps: scopes.timestamps || false,
@@ -102,6 +106,7 @@ function defineModel(options) {
102
106
  allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
103
107
  },
104
108
  hidden: Array.isArray(hidden) ? hidden : [],
109
+ queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
105
110
  hooks: {},
106
111
  cache: cacheOption,
107
112
  };
@@ -186,7 +191,10 @@ function unregisterModel(name) {
186
191
  * @returns {import('./types').ModelDefinition}
187
192
  */
188
193
  function resolveRelationModel(relation) {
189
- const model = relation.model();
194
+ let model = typeof relation.model === 'function' ? relation.model() : relation.model;
195
+ if (typeof model === 'string') {
196
+ model = getModel(model);
197
+ }
190
198
  if (!model || !model.name) {
191
199
  throw new Error('Invalid relation model reference');
192
200
  }
@@ -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
 
@@ -16,6 +16,7 @@ const {
16
16
  const { ensureArray } = require('./utils');
17
17
  const { ModelEvents, createEventContext, Hooks, HookCancellationError } = require('./events');
18
18
  const { getJsonColumns, serializeJsonFields, deserializeJsonFields } = require('./json-fields');
19
+ const { getAmbientTransaction } = require('./transaction');
19
20
 
20
21
  /**
21
22
  * Create a repository for a model
@@ -30,12 +31,37 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
30
31
  const jsonColumns = getJsonColumns(model);
31
32
  const hasJson = jsonColumns.size > 0;
32
33
 
34
+ /**
35
+ * Resolve active Knex instance (explicit or ambient transaction)
36
+ * @returns {import('knex').Knex|import('knex').Knex.Transaction}
37
+ */
38
+ function getActiveKnex() {
39
+ if (knex && (knex.isTransaction || typeof knex.commit === 'function')) {
40
+ return knex;
41
+ }
42
+ const ambientTrx = getAmbientTransaction();
43
+ if (ambientTrx) {
44
+ return ambientTrx;
45
+ }
46
+ return knex;
47
+ }
48
+
49
+ /**
50
+ * Get transaction handle for lifecycle hooks
51
+ * @returns {import('knex').Knex.Transaction|null}
52
+ */
53
+ function getHookTrx() {
54
+ const active = getActiveKnex();
55
+ return (active && (active.isTransaction || typeof active.commit === 'function')) ? active : null;
56
+ }
57
+
33
58
  /**
34
59
  * Get base query builder
35
60
  * @returns {import('knex').Knex.QueryBuilder}
36
61
  */
37
62
  function baseQuery() {
38
- let qb = knex(model.table);
63
+ const activeKnex = getActiveKnex();
64
+ let qb = activeKnex(model.table);
39
65
  return applyScopes(qb, scopeContext, model);
40
66
  }
41
67
 
@@ -79,7 +105,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
79
105
  }
80
106
 
81
107
  if (withs.length > 0) {
82
- await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
108
+ await loadRelations([record], ensureArray(withs), model, getActiveKnex(), scopeContext);
83
109
  }
84
110
 
85
111
  if (shouldEmit) {
@@ -105,7 +131,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
105
131
  kind,
106
132
  kind === 'pk' ? id : null
107
133
  );
108
- return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
134
+ return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
109
135
  }
110
136
 
111
137
  return loadFromDb();
@@ -160,7 +186,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
160
186
  }
161
187
 
162
188
  if (withs.length > 0) {
163
- await loadRelations([record], ensureArray(withs), model, knex, scopeContext);
189
+ await loadRelations([record], ensureArray(withs), model, getActiveKnex(), scopeContext);
164
190
  }
165
191
 
166
192
  if (shouldEmit) {
@@ -172,7 +198,18 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
172
198
 
173
199
  if (cacheLayer && cacheLayer.strategyFor(model)) {
174
200
  const strat = cacheLayer.strategyFor(model);
175
- const kind = isPkOnly ? 'pk' : 'collection';
201
+ if (isPkOnly) {
202
+ const pkVal = conditions[model.primaryKey];
203
+ const fingerprint = cacheLayer.findByIdFingerprint(
204
+ model,
205
+ scopeContext,
206
+ pkVal,
207
+ select || [],
208
+ withs
209
+ );
210
+ const tags = cacheLayer.buildReadTags(model, strat, 'pk', pkVal);
211
+ return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
212
+ }
176
213
  const fingerprint = cacheLayer.findOneFingerprint(
177
214
  model,
178
215
  scopeContext,
@@ -180,20 +217,15 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
180
217
  select || [],
181
218
  withs
182
219
  );
183
- const tags = cacheLayer.buildReadTags(
184
- model,
185
- strat,
186
- kind,
187
- kind === 'pk' ? conditions[model.primaryKey] : null
188
- );
189
- return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
220
+ const tags = cacheLayer.buildReadTags(model, strat, 'collection', null);
221
+ return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, (r) => r != null);
190
222
  }
191
223
 
192
224
  return loadFromDb();
193
225
  }
194
226
 
195
227
  /**
196
- * Find all records
228
+ * Find all records matching scope
197
229
  * @param {import('./types').FindOptions} [options={}] - Find options
198
230
  * @returns {Promise<Object[]>}
199
231
  */
@@ -229,7 +261,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
229
261
  }
230
262
 
231
263
  if (withs.length > 0 && records.length > 0) {
232
- await loadRelations(records, ensureArray(withs), model, knex, scopeContext);
264
+ await loadRelations(records, ensureArray(withs), model, getActiveKnex(), scopeContext);
233
265
  }
234
266
 
235
267
  if (shouldEmit) {
@@ -246,7 +278,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
246
278
  const strat = cacheLayer.strategyFor(model);
247
279
  const fingerprint = cacheLayer.findAllFingerprint(model, scopeContext, select || []);
248
280
  const tags = cacheLayer.buildReadTags(model, strat, 'collection', null);
249
- return cacheLayer.wrapRead(model, knex, scopeContext, fingerprint, tags, loadFromDb, () => true);
281
+ return cacheLayer.wrapRead(model, getActiveKnex(), scopeContext, fingerprint, tags, loadFromDb, () => true);
250
282
  }
251
283
 
252
284
  return loadFromDb();
@@ -258,7 +290,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
258
290
  * @returns {Promise<Object>}
259
291
  */
260
292
  async function create(data) {
261
- const ctx = createEventContext(model.name, 'create', knex.isTransaction ? knex : null);
293
+ const ctx = createEventContext(model.name, 'create', getHookTrx());
262
294
  let workingData = { ...data };
263
295
 
264
296
  // Emit beforeValidation
@@ -294,7 +326,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
294
326
  }
295
327
 
296
328
  // Insert and return the record
297
- const [id] = await knex(model.table).insert(insertData).returning(model.primaryKey);
329
+ const [id] = await getActiveKnex()(model.table).insert(insertData).returning(model.primaryKey);
298
330
 
299
331
  // For databases that don't support returning (SQLite), id might be the row itself
300
332
  const insertedId = typeof id === 'object' ? id[model.primaryKey] : id;
@@ -334,7 +366,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
334
366
  * @returns {Promise<Object|null>}
335
367
  */
336
368
  async function update(id, data) {
337
- const ctx = createEventContext(model.name, 'update', knex.isTransaction ? knex : null);
369
+ const ctx = createEventContext(model.name, 'update', getHookTrx());
338
370
  // Don't include primary key in working data for validation
339
371
  // to avoid type mismatch issues (URL params come as strings)
340
372
  let workingData = { ...data };
@@ -431,7 +463,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
431
463
  * @returns {Promise<boolean>}
432
464
  */
433
465
  async function del(id) {
434
- const ctx = createEventContext(model.name, 'delete', knex.isTransaction ? knex : null);
466
+ const ctx = createEventContext(model.name, 'delete', getHookTrx());
435
467
 
436
468
  // Get the record before deletion for hooks
437
469
  const record = await findById(id);
@@ -476,7 +508,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
476
508
  */
477
509
  async function forceDelete(id) {
478
510
  // Use raw query to bypass soft delete scope
479
- const deleted = await knex(model.table)
511
+ const deleted = await getActiveKnex()(model.table)
480
512
  .where(model.primaryKey, id)
481
513
  .delete();
482
514
  if (cacheLayer && deleted > 0) {
@@ -495,10 +527,10 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
495
527
  throw new Error(`Model "${model.name}" does not have soft delete enabled`);
496
528
  }
497
529
 
498
- const ctx = createEventContext(model.name, 'restore', knex.isTransaction ? knex : null);
530
+ const ctx = createEventContext(model.name, 'restore', getHookTrx());
499
531
 
500
532
  // Get the trashed record for hooks (bypass soft delete scope)
501
- const trashedRecord = await knex(model.table)
533
+ const trashedRecord = await getActiveKnex()(model.table)
502
534
  .where(model.primaryKey, id)
503
535
  .whereNotNull('deleted_at')
504
536
  .first();
@@ -514,7 +546,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
514
546
  }
515
547
 
516
548
  // Update directly without scopes (to find trashed record)
517
- const updated = await knex(model.table)
549
+ const updated = await getActiveKnex()(model.table)
518
550
  .where(model.primaryKey, id)
519
551
  .whereNotNull('deleted_at')
520
552
  .update({ deleted_at: null });
@@ -536,7 +568,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
536
568
  * @returns {import('./query-builder').QueryBuilder}
537
569
  */
538
570
  function query() {
539
- return createQueryBuilder(model, knex, scopeContext, cacheLayer);
571
+ return createQueryBuilder(model, getActiveKnex(), scopeContext, cacheLayer);
540
572
  }
541
573
 
542
574
  /**
@@ -546,7 +578,7 @@ function createRepository(model, knex, initialContext, cacheLayer = null) {
546
578
  * @returns {Promise<Object[]>}
547
579
  */
548
580
  async function raw(sql, bindings = []) {
549
- const result = await knex.raw(sql, bindings);
581
+ const result = await getActiveKnex().raw(sql, bindings);
550
582
  // Normalize result across database drivers
551
583
  return result.rows || result;
552
584
  }
@@ -185,11 +185,12 @@ function applyUpdateModifiers(data, model) {
185
185
  * Create default scope context
186
186
  * @returns {import('./types').ScopeContext}
187
187
  */
188
- function createScopeContext() {
188
+ function createScopeContext(overrides = {}) {
189
189
  return {
190
190
  tenantId: undefined,
191
191
  withTrashed: false,
192
192
  onlyTrashed: false,
193
+ ...overrides,
193
194
  };
194
195
  }
195
196
 
@@ -1,12 +1,42 @@
1
1
  /**
2
2
  * Webspresso ORM - Transaction
3
- * Transaction wrapper with scoped repositories
3
+ * Transaction wrapper with scoped repositories and AsyncLocalStorage ambient transaction support
4
4
  * @module core/orm/transaction
5
5
  */
6
6
 
7
- const { createRepository } = require('./repository');
7
+ const { AsyncLocalStorage } = require('async_hooks');
8
8
  const { createScopeContext } = require('./scopes');
9
9
 
10
+ // AsyncLocalStorage instance holding the current active ambient transaction
11
+ const ambientTransactionStorage = new AsyncLocalStorage();
12
+
13
+ /**
14
+ * Execute a callback within an ambient transaction context
15
+ * @param {import('knex').Knex.Transaction} trx - Knex transaction
16
+ * @param {Function} callback - Function to execute
17
+ * @returns {Promise<*>} Result of callback
18
+ */
19
+ function runWithAmbientTransaction(trx, callback) {
20
+ return ambientTransactionStorage.run({ trx }, callback);
21
+ }
22
+
23
+ /**
24
+ * Get the currently active ambient Knex transaction, or null if none
25
+ * @returns {import('knex').Knex.Transaction|null}
26
+ */
27
+ function getAmbientTransaction() {
28
+ const store = ambientTransactionStorage.getStore();
29
+ return store && store.trx ? store.trx : null;
30
+ }
31
+
32
+ /**
33
+ * Check if there is an active ambient Knex transaction in current execution context
34
+ * @returns {boolean}
35
+ */
36
+ function hasAmbientTransaction() {
37
+ return Boolean(getAmbientTransaction());
38
+ }
39
+
10
40
  /**
11
41
  * Create a transaction context
12
42
  * @param {import('knex').Knex.Transaction} trx - Knex transaction
@@ -14,6 +44,7 @@ const { createScopeContext } = require('./scopes');
14
44
  * @returns {import('./types').TransactionContext}
15
45
  */
16
46
  function createTransactionContext(trx, scopeContext) {
47
+ const { createRepository } = require('./repository');
17
48
  const context = scopeContext || createScopeContext();
18
49
 
19
50
  return {
@@ -49,7 +80,7 @@ function createTransactionContext(trx, scopeContext) {
49
80
  }
50
81
 
51
82
  /**
52
- * Run a callback within a transaction
83
+ * Run a callback within a transaction and bind ambient transaction storage
53
84
  * @param {import('knex').Knex} knex - Knex instance
54
85
  * @param {function(import('./types').TransactionContext): Promise<*>} callback - Transaction callback
55
86
  * @param {import('./types').ScopeContext} [scopeContext] - Scope context
@@ -58,12 +89,15 @@ function createTransactionContext(trx, scopeContext) {
58
89
  async function runTransaction(knex, callback, scopeContext) {
59
90
  return knex.transaction(async (trx) => {
60
91
  const ctx = createTransactionContext(trx, scopeContext);
61
- return callback(ctx);
92
+ return runWithAmbientTransaction(trx, () => callback(ctx));
62
93
  });
63
94
  }
64
95
 
65
96
  module.exports = {
97
+ ambientTransactionStorage,
98
+ runWithAmbientTransaction,
99
+ getAmbientTransaction,
100
+ hasAmbientTransaction,
66
101
  createTransactionContext,
67
102
  runTransaction,
68
103
  };
69
-