webspresso 0.0.90 → 0.0.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +358 -8
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/middleware.js +9 -0
- 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 +2 -0
- 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 +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 +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- 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 +17 -24
- 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 +90 -17
- 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/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 +196 -21
- package/src/helpers.js +20 -2
- package/src/plugin-manager.js +124 -4
- package/src/server.js +304 -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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Normalization & Formatting Utilities
|
|
3
|
+
* Standardizes errors across all layers and formats safe HTTP error payloads.
|
|
4
|
+
* @module core/errors/normalize
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { WebspressoError } = require('./base');
|
|
8
|
+
const { HttpError } = require('./http');
|
|
9
|
+
|
|
10
|
+
const HTTP_TITLES = {
|
|
11
|
+
400: 'Bad Request',
|
|
12
|
+
401: 'Unauthorized',
|
|
13
|
+
403: 'Forbidden',
|
|
14
|
+
404: 'Not Found',
|
|
15
|
+
405: 'Method Not Allowed',
|
|
16
|
+
408: 'Request Timeout',
|
|
17
|
+
409: 'Conflict',
|
|
18
|
+
413: 'Payload Too Large',
|
|
19
|
+
415: 'Unsupported Media Type',
|
|
20
|
+
422: 'Unprocessable Entity',
|
|
21
|
+
429: 'Too Many Requests',
|
|
22
|
+
500: 'Internal Server Error',
|
|
23
|
+
502: 'Bad Gateway',
|
|
24
|
+
503: 'Service Unavailable',
|
|
25
|
+
504: 'Gateway Timeout',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get human-readable title for an HTTP status code
|
|
30
|
+
* @param {number} status
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function getStatusTitle(status) {
|
|
34
|
+
return HTTP_TITLES[status] || (status >= 500 ? 'Internal Server Error' : 'Error');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize any thrown value (Error, string, object, null) into a standardized HttpError or WebspressoError
|
|
39
|
+
* @param {unknown} err - The raw thrown error
|
|
40
|
+
* @param {boolean} [isDev=false] - Whether in development environment
|
|
41
|
+
* @returns {HttpError|WebspressoError}
|
|
42
|
+
*/
|
|
43
|
+
function normalizeError(err, isDev = false) {
|
|
44
|
+
// Handle nullish throws
|
|
45
|
+
if (err === null || err === undefined) {
|
|
46
|
+
return new HttpError(500, 'Internal Server Error', {
|
|
47
|
+
expose: false,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If already a WebspressoError / HttpError
|
|
52
|
+
if (err instanceof WebspressoError) {
|
|
53
|
+
return err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// If standard JavaScript Error (TypeError, RangeError, standard Error)
|
|
57
|
+
if (err instanceof Error) {
|
|
58
|
+
const status = typeof err.status === 'number'
|
|
59
|
+
? err.status
|
|
60
|
+
: (typeof err.statusCode === 'number' ? err.statusCode : 500);
|
|
61
|
+
|
|
62
|
+
const isClientError = status < 500;
|
|
63
|
+
const expose = typeof err.expose === 'boolean' ? err.expose : isClientError;
|
|
64
|
+
|
|
65
|
+
const httpErr = new HttpError(
|
|
66
|
+
status,
|
|
67
|
+
(expose || isDev) ? (err.message || getStatusTitle(status)) : getStatusTitle(status),
|
|
68
|
+
{
|
|
69
|
+
code: err.code || (status === 500 ? 'INTERNAL_SERVER_ERROR' : undefined),
|
|
70
|
+
details: err.details,
|
|
71
|
+
cause: err,
|
|
72
|
+
headers: err.headers,
|
|
73
|
+
expose,
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Retain original stack
|
|
78
|
+
if (err.stack) {
|
|
79
|
+
httpErr.stack = err.stack;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return httpErr;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// If primitive string or number thrown
|
|
86
|
+
if (typeof err === 'string' || typeof err === 'number' || typeof err === 'boolean') {
|
|
87
|
+
const msg = String(err);
|
|
88
|
+
return new HttpError(500, isDev ? msg : 'Internal Server Error', {
|
|
89
|
+
details: { rawValue: err },
|
|
90
|
+
expose: false,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If plain object thrown (e.g. { message: 'Failed', status: 400 })
|
|
95
|
+
if (typeof err === 'object') {
|
|
96
|
+
const status = typeof err.status === 'number'
|
|
97
|
+
? err.status
|
|
98
|
+
: (typeof err.statusCode === 'number' ? err.statusCode : 500);
|
|
99
|
+
|
|
100
|
+
const msg = typeof err.message === 'string' ? err.message : getStatusTitle(status);
|
|
101
|
+
|
|
102
|
+
return new HttpError(status, (status < 500 || isDev) ? msg : getStatusTitle(status), {
|
|
103
|
+
code: err.code,
|
|
104
|
+
details: err.details,
|
|
105
|
+
headers: err.headers,
|
|
106
|
+
expose: typeof err.expose === 'boolean' ? err.expose : status < 500,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new HttpError(500, 'Internal Server Error', { expose: false });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Format a normalized error into a clean JSON response object conforming to contract
|
|
115
|
+
* @param {HttpError|WebspressoError|Error} error - Normalized error
|
|
116
|
+
* @param {boolean} [isDev=false] - Whether running in development mode
|
|
117
|
+
* @returns {Record<string, unknown>}
|
|
118
|
+
*/
|
|
119
|
+
function toErrorResponseObject(error, isDev = false) {
|
|
120
|
+
const status = typeof error.status === 'number' ? error.status : 500;
|
|
121
|
+
const errorTitle = error.name === 'ValidationError'
|
|
122
|
+
? 'Validation Error'
|
|
123
|
+
: getStatusTitle(status);
|
|
124
|
+
|
|
125
|
+
let shouldExpose;
|
|
126
|
+
if (typeof error.expose === 'boolean') {
|
|
127
|
+
shouldExpose = error.expose || isDev;
|
|
128
|
+
} else {
|
|
129
|
+
shouldExpose = isDev || status < 500;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const message = shouldExpose && error.message ? error.message : getStatusTitle(status);
|
|
133
|
+
|
|
134
|
+
const payload = {
|
|
135
|
+
status,
|
|
136
|
+
error: errorTitle,
|
|
137
|
+
message,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (error.code) {
|
|
141
|
+
payload.code = error.code;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Include validation fields if present
|
|
145
|
+
if (error.fields && Object.keys(error.fields).length > 0 && shouldExpose) {
|
|
146
|
+
payload.fields = error.fields;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Include contextual details if expose is true or in dev
|
|
150
|
+
if (error.details !== undefined && shouldExpose) {
|
|
151
|
+
payload.details = error.details;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Include stack and cause strictly in development mode
|
|
155
|
+
if (isDev) {
|
|
156
|
+
if (error.stack) {
|
|
157
|
+
payload.stack = error.stack;
|
|
158
|
+
}
|
|
159
|
+
if (error.cause) {
|
|
160
|
+
payload.cause = error.cause instanceof Error ? (error.cause.stack || error.cause.message) : error.cause;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
getStatusTitle,
|
|
169
|
+
normalizeError,
|
|
170
|
+
toErrorResponseObject,
|
|
171
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORM Query Complexity & DoS Protection
|
|
3
|
+
* @module core/orm/complexity
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { BadRequestError } = require('../errors/http');
|
|
9
|
+
|
|
10
|
+
class QueryComplexityError extends BadRequestError {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} message
|
|
13
|
+
* @param {Object} [details]
|
|
14
|
+
*/
|
|
15
|
+
constructor(message, details = {}) {
|
|
16
|
+
super(message || 'Query complexity limit exceeded', details);
|
|
17
|
+
this.name = 'QueryComplexityError';
|
|
18
|
+
this.code = 'QUERY_COMPLEXITY_EXCEEDED';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_QUERY_LIMITS = Object.freeze({
|
|
23
|
+
maxLimit: 100,
|
|
24
|
+
defaultLimit: 15,
|
|
25
|
+
maxIncludes: 5,
|
|
26
|
+
maxFilterConditions: 25,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve query limits for a given model
|
|
31
|
+
* @param {import('./types').ModelDefinition} [model]
|
|
32
|
+
* @returns {typeof DEFAULT_QUERY_LIMITS}
|
|
33
|
+
*/
|
|
34
|
+
function resolveQueryLimits(model) {
|
|
35
|
+
if (!model || !model.queryLimits) {
|
|
36
|
+
return DEFAULT_QUERY_LIMITS;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
maxLimit: Number(model.queryLimits.maxLimit) || DEFAULT_QUERY_LIMITS.maxLimit,
|
|
40
|
+
defaultLimit: Number(model.queryLimits.defaultLimit) || DEFAULT_QUERY_LIMITS.defaultLimit,
|
|
41
|
+
maxIncludes: Number(model.queryLimits.maxIncludes) || DEFAULT_QUERY_LIMITS.maxIncludes,
|
|
42
|
+
maxFilterConditions: Number(model.queryLimits.maxFilterConditions) || DEFAULT_QUERY_LIMITS.maxFilterConditions,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Validate and clamp query complexity parameters against model / default limits
|
|
48
|
+
* @param {import('./types').ModelDefinition} [model]
|
|
49
|
+
* @param {Object} params
|
|
50
|
+
* @param {number} [params.perPage]
|
|
51
|
+
* @param {number} [params.limit]
|
|
52
|
+
* @param {string[]} [params.includes]
|
|
53
|
+
* @param {number} [params.filterCount]
|
|
54
|
+
* @param {boolean} [params.strict=false] - If true, throws QueryComplexityError instead of clamping
|
|
55
|
+
* @returns {{ limit: number, perPage: number, includes: string[] }}
|
|
56
|
+
*/
|
|
57
|
+
function validateQueryComplexity(model, params = {}) {
|
|
58
|
+
const limits = resolveQueryLimits(model);
|
|
59
|
+
const strict = params.strict === true;
|
|
60
|
+
|
|
61
|
+
// 1. Validate includes
|
|
62
|
+
let includes = Array.isArray(params.includes) ? params.includes : [];
|
|
63
|
+
if (includes.length > limits.maxIncludes) {
|
|
64
|
+
if (strict) {
|
|
65
|
+
throw new QueryComplexityError(
|
|
66
|
+
`Too many eager-loaded relations (${includes.length}). Maximum allowed is ${limits.maxIncludes}.`,
|
|
67
|
+
{ maxIncludes: limits.maxIncludes, requested: includes.length }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
includes = includes.slice(0, limits.maxIncludes);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Validate perPage / limit
|
|
74
|
+
let requestedLimit = params.perPage !== undefined ? params.perPage : params.limit;
|
|
75
|
+
if (requestedLimit !== undefined) {
|
|
76
|
+
const numLimit = parseInt(requestedLimit, 10);
|
|
77
|
+
if (!isNaN(numLimit)) {
|
|
78
|
+
if (numLimit > limits.maxLimit) {
|
|
79
|
+
if (strict) {
|
|
80
|
+
throw new QueryComplexityError(
|
|
81
|
+
`Requested limit (${numLimit}) exceeds maximum allowed limit of ${limits.maxLimit}.`,
|
|
82
|
+
{ maxLimit: limits.maxLimit, requested: numLimit }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
requestedLimit = limits.maxLimit;
|
|
86
|
+
} else if (numLimit < 1) {
|
|
87
|
+
requestedLimit = limits.defaultLimit;
|
|
88
|
+
} else {
|
|
89
|
+
requestedLimit = numLimit;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
requestedLimit = limits.defaultLimit;
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
requestedLimit = limits.defaultLimit;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Validate filter count
|
|
99
|
+
if (params.filterCount !== undefined && params.filterCount > limits.maxFilterConditions) {
|
|
100
|
+
if (strict) {
|
|
101
|
+
throw new QueryComplexityError(
|
|
102
|
+
`Too many filter conditions (${params.filterCount}). Maximum allowed is ${limits.maxFilterConditions}.`,
|
|
103
|
+
{ maxFilterConditions: limits.maxFilterConditions, requested: params.filterCount }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
limit: requestedLimit,
|
|
110
|
+
perPage: requestedLimit,
|
|
111
|
+
includes,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
QueryComplexityError,
|
|
117
|
+
DEFAULT_QUERY_LIMITS,
|
|
118
|
+
resolveQueryLimits,
|
|
119
|
+
validateQueryComplexity,
|
|
120
|
+
};
|
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
|
|
|
@@ -105,6 +106,7 @@ function defineModel(options) {
|
|
|
105
106
|
allowInclude: Array.isArray(rest.allowInclude) ? rest.allowInclude.filter((x) => typeof x === 'string') : null,
|
|
106
107
|
},
|
|
107
108
|
hidden: Array.isArray(hidden) ? hidden : [],
|
|
109
|
+
queryLimits: typeof queryLimits === 'object' && queryLimits !== null ? queryLimits : {},
|
|
108
110
|
hooks: {},
|
|
109
111
|
cache: cacheOption,
|
|
110
112
|
};
|
|
@@ -13,6 +13,8 @@ const {
|
|
|
13
13
|
serializeJsonFields,
|
|
14
14
|
deserializeJsonFields,
|
|
15
15
|
} = require('./json-fields');
|
|
16
|
+
const { getAmbientTransaction } = require('./transaction');
|
|
17
|
+
const { validateQueryComplexity } = require('./complexity');
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Create a new query builder
|
|
@@ -236,7 +238,8 @@ class QueryBuilder {
|
|
|
236
238
|
* @returns {this}
|
|
237
239
|
*/
|
|
238
240
|
limit(limit) {
|
|
239
|
-
this.
|
|
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
|
|