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.
- package/README.md +450 -53
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +21 -2
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +10 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/scopes.js +2 -1
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +67 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +131 -18
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +220 -28
- package/src/helpers.js +24 -5
- package/src/plugin-manager.js +134 -7
- package/src/server.js +470 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/core/orm/events.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
139
|
+
* @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
132
140
|
* @returns {import('./types').ModelDefinition}
|
|
133
141
|
*/
|
|
134
|
-
function getModelInstance(
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/core/orm/repository.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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',
|
|
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
|
|
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',
|
|
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',
|
|
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
|
|
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',
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
581
|
+
const result = await getActiveKnex().raw(sql, bindings);
|
|
550
582
|
// Normalize result across database drivers
|
|
551
583
|
return result.rows || result;
|
|
552
584
|
}
|
package/core/orm/scopes.js
CHANGED
|
@@ -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
|
|
package/core/orm/transaction.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|