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.
- package/README.md +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -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/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -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/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- 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 +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- 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/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -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 +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- 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 +141 -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 +290 -0
- package/src/services/validator.js +230 -0
- 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
|
+
};
|
package/core/orm/eager-loader.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
@@ -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(
|
|
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}
|
|
175
|
+
* @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
132
176
|
* @returns {import('./types').ModelDefinition}
|
|
133
177
|
*/
|
|
134
|
-
function getModelInstance(
|
|
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
|
|
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,
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
|