webspresso 0.0.102 → 0.0.103
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/.agents/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/core/auth/jwt.js +10 -17
- package/core/auth/user-agent.js +61 -42
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/events/dispatcher.js +56 -33
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +22 -5
- package/package.json +2 -1
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/site-analytics/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +245 -46
- package/src/api/api-loader.js +1 -1
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +139 -26
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +23 -7
- package/src/services/validator.js +57 -32
package/.agents/ORM.md
CHANGED
|
@@ -51,7 +51,9 @@ import { defineModel, zdb } from 'webspresso';
|
|
|
51
51
|
export default defineModel({
|
|
52
52
|
name: 'Product',
|
|
53
53
|
table: 'products',
|
|
54
|
-
|
|
54
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
55
|
+
softDelete: true, // auto-injects deletedAt & manages soft-delete scope
|
|
56
|
+
schema: {
|
|
55
57
|
id: zdb.id(),
|
|
56
58
|
name: zdb.string().min(2),
|
|
57
59
|
slug: zdb.string().unique(),
|
|
@@ -61,9 +63,7 @@ export default defineModel({
|
|
|
61
63
|
metadata: zdb.json({ optional: true }),
|
|
62
64
|
category_id: zdb.integer(),
|
|
63
65
|
active: zdb.boolean({ default: true }),
|
|
64
|
-
|
|
65
|
-
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
66
|
-
}),
|
|
66
|
+
},
|
|
67
67
|
relations: {
|
|
68
68
|
category: { type: 'belongsTo', model: 'Category', foreignKey: 'category_id' },
|
|
69
69
|
reviews: { type: 'hasMany', model: 'Review', foreignKey: 'product_id' },
|
|
@@ -81,6 +81,13 @@ export default defineModel({
|
|
|
81
81
|
});
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Automatic Timestamps & Soft Deletes (`timestamps`, `softDelete`)
|
|
85
|
+
Instead of manually defining boilerplate timestamp and soft delete columns in `schema`, configure them declaratively:
|
|
86
|
+
- **Top-level / Options:** `timestamps: true` (or `options: { timestamps: true }`) automatically injects `createdAt` and `updatedAt` into the Zod schema, validation rules, column map, and Knex migrations.
|
|
87
|
+
- **Soft Deletes:** `softDelete: true` (or `softDeletes: true`) automatically injects `deletedAt` (nullable/optional) into the schema and activates the repository `whereNull('deletedAt')` soft-delete scope.
|
|
88
|
+
- **Custom column names & snake_case:** `timestamps: { snakeCase: true }` or `timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }`, `softDelete: { deletedAt: 'deleted_at' }`.
|
|
89
|
+
- **Explicit Schema Spread:** `...zdb.timestamps()` and `...zdb.softDeletes()` are also supported for explicit schema definitions.
|
|
90
|
+
|
|
84
91
|
### Supported `zdb` Data Types
|
|
85
92
|
- `zdb.id()` — Primary key (BigInt / Auto-increment)
|
|
86
93
|
- `zdb.string({ max, unique, optional, sortable })` — String / VarChar
|
|
@@ -90,6 +97,8 @@ export default defineModel({
|
|
|
90
97
|
- `zdb.file()` — File asset URL / upload path (non-sortable by default)
|
|
91
98
|
- `zdb.json()` — Serialized JSON column (non-sortable by default)
|
|
92
99
|
- `zdb.timestamp({ auto, sortable })` — Timestamp (`auto: 'create'` / `auto: 'update'`)
|
|
100
|
+
- `zdb.timestamps({ snakeCase })` — Timestamp pair (`createdAt`, `updatedAt`)
|
|
101
|
+
- `zdb.softDelete()`, `zdb.softDeletes({ snakeCase })` — Soft delete timestamp (`deletedAt`)
|
|
93
102
|
|
|
94
103
|
*Note: All `zdb` types support chainable `.config({ sortable: boolean })` or options object `{ sortable: boolean }`.*
|
|
95
104
|
|
|
@@ -21,8 +21,10 @@ module.exports = defineModel({
|
|
|
21
21
|
name: 'Product',
|
|
22
22
|
table: 'products',
|
|
23
23
|
primaryKey: 'id',
|
|
24
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
25
|
+
softDelete: true, // auto-injects deletedAt & enables soft-delete scope
|
|
24
26
|
|
|
25
|
-
schema:
|
|
27
|
+
schema: {
|
|
26
28
|
id: zdb.id(),
|
|
27
29
|
name: zdb.string().min(1).max(255),
|
|
28
30
|
slug: zdb.string().unique(),
|
|
@@ -30,10 +32,7 @@ module.exports = defineModel({
|
|
|
30
32
|
in_stock: zdb.boolean().default(true),
|
|
31
33
|
metadata: zdb.json().optional(),
|
|
32
34
|
user_id: zdb.foreignKey('users.id'),
|
|
33
|
-
|
|
34
|
-
updated_at: zdb.timestamp().autoUpdate(),
|
|
35
|
-
deleted_at: zdb.timestamp().nullable(),
|
|
36
|
-
}),
|
|
35
|
+
},
|
|
37
36
|
|
|
38
37
|
relations: {
|
|
39
38
|
user: {
|
package/README.md
CHANGED
|
@@ -115,13 +115,13 @@ const { defineModel, zdb } = require('webspresso');
|
|
|
115
115
|
module.exports = defineModel({
|
|
116
116
|
name: 'Note',
|
|
117
117
|
table: 'notes',
|
|
118
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
119
|
+
softDelete: true, // auto-injects deletedAt & enables soft-delete scope
|
|
118
120
|
schema: {
|
|
119
121
|
id: zdb.id(),
|
|
120
122
|
title: zdb.string(200),
|
|
121
123
|
content: zdb.text(),
|
|
122
124
|
userId: zdb.integer(),
|
|
123
|
-
...zdb.timestamps(),
|
|
124
|
-
...zdb.softDeletes(),
|
|
125
125
|
},
|
|
126
126
|
relations: {
|
|
127
127
|
user: { type: 'belongsTo', model: 'User', foreignKey: 'userId' },
|
package/core/auth/jwt.js
CHANGED
|
@@ -6,18 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
const crypto = require('crypto');
|
|
8
8
|
|
|
9
|
+
const HS256_HEADER_BASE64URL = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Base64URL encode string or Buffer
|
|
11
13
|
* @param {string|Buffer} input
|
|
12
14
|
* @returns {string}
|
|
13
15
|
*/
|
|
14
16
|
function base64UrlEncode(input) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.toString('
|
|
18
|
-
.replace(/=/g, '')
|
|
19
|
-
.replace(/\+/g, '-')
|
|
20
|
-
.replace(/\//g, '_');
|
|
17
|
+
return Buffer.isBuffer(input)
|
|
18
|
+
? input.toString('base64url')
|
|
19
|
+
: Buffer.from(String(input), 'utf8').toString('base64url');
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
/**
|
|
@@ -26,11 +25,7 @@ function base64UrlEncode(input) {
|
|
|
26
25
|
* @returns {string}
|
|
27
26
|
*/
|
|
28
27
|
function base64UrlDecode(input) {
|
|
29
|
-
|
|
30
|
-
while (base64.length % 4 !== 0) {
|
|
31
|
-
base64 += '=';
|
|
32
|
-
}
|
|
33
|
-
return Buffer.from(base64, 'base64').toString('utf8');
|
|
28
|
+
return Buffer.from(String(input), 'base64url').toString('utf8');
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
/**
|
|
@@ -110,16 +105,14 @@ function signJwt(payload, secret, options = {}) {
|
|
|
110
105
|
...payload,
|
|
111
106
|
};
|
|
112
107
|
|
|
113
|
-
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
108
|
+
const encodedHeader = algorithm === 'HS256' ? HS256_HEADER_BASE64URL : base64UrlEncode(JSON.stringify(header));
|
|
114
109
|
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
|
|
115
110
|
|
|
116
111
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
117
|
-
const
|
|
112
|
+
const encodedSignature = crypto
|
|
118
113
|
.createHmac('sha256', secret)
|
|
119
114
|
.update(signingInput)
|
|
120
|
-
.digest();
|
|
121
|
-
|
|
122
|
-
const encodedSignature = base64UrlEncode(signature);
|
|
115
|
+
.digest('base64url');
|
|
123
116
|
|
|
124
117
|
return `${signingInput}.${encodedSignature}`;
|
|
125
118
|
}
|
|
@@ -177,7 +170,7 @@ function verifyJwt(token, secret, options = {}) {
|
|
|
177
170
|
.update(signingInput)
|
|
178
171
|
.digest();
|
|
179
172
|
|
|
180
|
-
const actualSignatureBuf = Buffer.from(encodedSignature
|
|
173
|
+
const actualSignatureBuf = Buffer.from(encodedSignature, 'base64url');
|
|
181
174
|
|
|
182
175
|
if (
|
|
183
176
|
expectedSignatureBuf.length !== actualSignatureBuf.length ||
|
package/core/auth/user-agent.js
CHANGED
|
@@ -39,6 +39,28 @@ function getClientIp(req) {
|
|
|
39
39
|
'127.0.0.1';
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Parse raw User-Agent header string into structured device info
|
|
44
|
+
* Zero external dependencies — pure regex extraction
|
|
45
|
+
*
|
|
46
|
+
* @param {string} [userAgent='']
|
|
47
|
+
* @returns {DeviceInfo}
|
|
48
|
+
*/
|
|
49
|
+
const MAX_UA_CACHE = 512;
|
|
50
|
+
const uaCache = new Map();
|
|
51
|
+
|
|
52
|
+
const EMPTY_DEVICE_INFO = Object.freeze({
|
|
53
|
+
browser: 'Unknown',
|
|
54
|
+
os: 'Unknown',
|
|
55
|
+
device: 'unknown',
|
|
56
|
+
isMobile: false,
|
|
57
|
+
isTablet: false,
|
|
58
|
+
isDesktop: false,
|
|
59
|
+
isBot: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const BOT_PATTERN = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
|
|
63
|
+
|
|
42
64
|
/**
|
|
43
65
|
* Parse raw User-Agent header string into structured device info
|
|
44
66
|
* Zero external dependencies — pure regex extraction
|
|
@@ -50,34 +72,30 @@ function parseUserAgent(userAgent = '') {
|
|
|
50
72
|
const ua = String(userAgent || '').trim();
|
|
51
73
|
|
|
52
74
|
if (!ua) {
|
|
53
|
-
return {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
isDesktop: false,
|
|
60
|
-
isBot: false,
|
|
61
|
-
};
|
|
75
|
+
return { ...EMPTY_DEVICE_INFO };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cached = uaCache.get(ua);
|
|
79
|
+
if (cached) {
|
|
80
|
+
return { ...cached };
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
// 1. Detect Bots / Crawlers
|
|
65
|
-
const
|
|
66
|
-
const isBot = botPattern.test(ua);
|
|
84
|
+
const isBot = BOT_PATTERN.test(ua);
|
|
67
85
|
|
|
68
86
|
// 2. Detect Operating System
|
|
69
87
|
let os = 'Unknown';
|
|
88
|
+
let match;
|
|
70
89
|
if (/iPad/i.test(ua)) {
|
|
71
|
-
|
|
90
|
+
match = ua.match(/OS ([\d_\.]+)/i);
|
|
72
91
|
os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
|
|
73
92
|
} else if (/iPhone|iPod/i.test(ua)) {
|
|
74
|
-
|
|
93
|
+
match = ua.match(/OS ([\d_\.]+)/i);
|
|
75
94
|
os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
|
|
76
|
-
} else if (/Android/i
|
|
77
|
-
|
|
78
|
-
os = match ? `Android ${match[1]}` : 'Android';
|
|
95
|
+
} else if ((match = ua.match(/Android (\d+(\.\d+)?)/i))) {
|
|
96
|
+
os = `Android ${match[1]}`;
|
|
79
97
|
} else if (/Macintosh|Mac OS X/i.test(ua)) {
|
|
80
|
-
|
|
98
|
+
match = ua.match(/Mac OS X ([\d_\.]+)/i);
|
|
81
99
|
if (match) {
|
|
82
100
|
const ver = match[1].replace(/_/g, '.');
|
|
83
101
|
os = `macOS ${ver}`;
|
|
@@ -100,30 +118,22 @@ function parseUserAgent(userAgent = '') {
|
|
|
100
118
|
|
|
101
119
|
// 3. Detect Browser
|
|
102
120
|
let browser = 'Unknown';
|
|
103
|
-
if (/Edg(e|A|iOS)?\/(\d+)/i
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
browser = `
|
|
109
|
-
} else if (/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
browser = `
|
|
115
|
-
} else if (/
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const match = ua.match(/Chrome\/(\d+)/i);
|
|
120
|
-
browser = `Chrome ${match ? match[1] : ''}`.trim();
|
|
121
|
-
} else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
|
|
122
|
-
const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
|
|
123
|
-
browser = `Firefox ${match ? match[1] : ''}`.trim();
|
|
124
|
-
} else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
|
|
125
|
-
const match = ua.match(/Version\/(\d+)/i);
|
|
126
|
-
browser = `Safari ${match ? match[1] : ''}`.trim();
|
|
121
|
+
if ((match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i))) {
|
|
122
|
+
browser = `Edge ${match[2] || ''}`.trim();
|
|
123
|
+
} else if ((match = ua.match(/OPR\/(\d+)/i))) {
|
|
124
|
+
browser = `Opera ${match[1] || ''}`.trim();
|
|
125
|
+
} else if ((match = ua.match(/SamsungBrowser\/(\d+)/i))) {
|
|
126
|
+
browser = `Samsung Internet ${match[1] || ''}`.trim();
|
|
127
|
+
} else if ((match = ua.match(/Brave\/(\d+)/i))) {
|
|
128
|
+
browser = `Brave ${match[1] || ''}`.trim();
|
|
129
|
+
} else if ((match = ua.match(/Vivaldi\/(\d+)/i))) {
|
|
130
|
+
browser = `Vivaldi ${match[1] || ''}`.trim();
|
|
131
|
+
} else if (!/Chromium/i.test(ua) && (match = ua.match(/Chrome\/(\d+)/i))) {
|
|
132
|
+
browser = `Chrome ${match[1] || ''}`.trim();
|
|
133
|
+
} else if ((match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i))) {
|
|
134
|
+
browser = `Firefox ${match[1] || ''}`.trim();
|
|
135
|
+
} else if ((match = ua.match(/Version\/(\d+)/i)) && /Safari/i.test(ua)) {
|
|
136
|
+
browser = `Safari ${match[1] || ''}`.trim();
|
|
127
137
|
} else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
|
|
128
138
|
browser = 'Safari';
|
|
129
139
|
} else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
|
|
@@ -144,7 +154,7 @@ function parseUserAgent(userAgent = '') {
|
|
|
144
154
|
else if (isMobile) device = 'mobile';
|
|
145
155
|
else if (isDesktop) device = 'desktop';
|
|
146
156
|
|
|
147
|
-
|
|
157
|
+
const result = {
|
|
148
158
|
browser,
|
|
149
159
|
os,
|
|
150
160
|
device,
|
|
@@ -153,9 +163,18 @@ function parseUserAgent(userAgent = '') {
|
|
|
153
163
|
isDesktop,
|
|
154
164
|
isBot,
|
|
155
165
|
};
|
|
166
|
+
|
|
167
|
+
if (uaCache.size >= MAX_UA_CACHE) {
|
|
168
|
+
const oldestKey = uaCache.keys().next().value;
|
|
169
|
+
uaCache.delete(oldestKey);
|
|
170
|
+
}
|
|
171
|
+
uaCache.set(ua, result);
|
|
172
|
+
|
|
173
|
+
return { ...result };
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
module.exports = {
|
|
159
177
|
getClientIp,
|
|
160
178
|
parseUserAgent,
|
|
161
179
|
};
|
|
180
|
+
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const zlib = require('zlib');
|
|
8
|
-
const
|
|
8
|
+
const negotiate = require('./negotiate');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Create a zlib compression transform stream based on encoding
|
|
@@ -23,7 +23,7 @@ function createCompressionStream(encoding, options = {}) {
|
|
|
23
23
|
|
|
24
24
|
switch (enc) {
|
|
25
25
|
case 'br': {
|
|
26
|
-
if (!supportsBrotli()) {
|
|
26
|
+
if (options.supportsBrotli === false || !negotiate.supportsBrotli()) {
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
const brotliOpts = { ...(options.brotli || {}) };
|
|
@@ -55,6 +55,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
55
55
|
const customFilter = typeof options.filter === 'function' ? options.filter : null;
|
|
56
56
|
|
|
57
57
|
const comp = compression({
|
|
58
|
+
...options,
|
|
58
59
|
threshold,
|
|
59
60
|
brotli: options.brotli,
|
|
60
61
|
level: options.level,
|
|
@@ -73,7 +74,6 @@ function createCompressionMiddleware(options = {}) {
|
|
|
73
74
|
}
|
|
74
75
|
return compression.filter(req, res);
|
|
75
76
|
},
|
|
76
|
-
...options,
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
return function compressionMiddleware(req, res, next) {
|
|
@@ -69,6 +69,9 @@ function parseAcceptEncoding(header) {
|
|
|
69
69
|
return results;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const DEFAULT_SUPPORTED_ENCODINGS = Object.freeze(getDefaultSupportedEncodings());
|
|
73
|
+
const DEFAULT_SUPPORTED_STRING = DEFAULT_SUPPORTED_ENCODINGS.join(',');
|
|
74
|
+
|
|
72
75
|
const selectEncodingCache = new Map();
|
|
73
76
|
const MAX_ENCODING_CACHE_SIZE = 128;
|
|
74
77
|
|
|
@@ -83,16 +86,20 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
|
|
|
83
86
|
return 'identity';
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
const isDefault = !supportedEncodings || !Array.isArray(supportedEncodings) || supportedEncodings.length === 0;
|
|
90
|
+
const cacheKey = isDefault
|
|
91
|
+
? `${acceptEncoding}|${DEFAULT_SUPPORTED_STRING}`
|
|
92
|
+
: `${acceptEncoding}|${supportedEncodings.join(',')}`;
|
|
89
93
|
|
|
90
|
-
const cacheKey = `${acceptEncoding}|${supported.join(',')}`;
|
|
91
94
|
const cached = selectEncodingCache.get(cacheKey);
|
|
92
95
|
if (cached !== undefined) {
|
|
93
96
|
return cached;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
const supported = isDefault
|
|
100
|
+
? DEFAULT_SUPPORTED_ENCODINGS
|
|
101
|
+
: supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli());
|
|
102
|
+
|
|
96
103
|
const clientPreferences = parseAcceptEncoding(acceptEncoding);
|
|
97
104
|
if (clientPreferences.length === 0) {
|
|
98
105
|
if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
|
|
@@ -12,10 +12,14 @@ const EventEmitter = require('events');
|
|
|
12
12
|
* @param {string} pattern
|
|
13
13
|
* @returns {RegExp}
|
|
14
14
|
*/
|
|
15
|
+
const patternRegexCache = new Map();
|
|
16
|
+
|
|
15
17
|
function patternToRegExp(pattern) {
|
|
16
18
|
if (pattern === '*' || pattern === '**') {
|
|
17
19
|
return /^.*$/;
|
|
18
20
|
}
|
|
21
|
+
const cached = patternRegexCache.get(pattern);
|
|
22
|
+
if (cached !== undefined) return cached;
|
|
19
23
|
|
|
20
24
|
const escaped = pattern
|
|
21
25
|
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
|
@@ -23,7 +27,11 @@ function patternToRegExp(pattern) {
|
|
|
23
27
|
.replace(/\*/g, '[^.]+')
|
|
24
28
|
.replace(/___DOUBLE_STAR___/g, '.*');
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
const reg = new RegExp(`^${escaped}$`);
|
|
31
|
+
if (patternRegexCache.size < 500) {
|
|
32
|
+
patternRegexCache.set(pattern, reg);
|
|
33
|
+
}
|
|
34
|
+
return reg;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -193,8 +201,9 @@ class EventDispatcher extends EventEmitter {
|
|
|
193
201
|
// 1. Exact listeners
|
|
194
202
|
if (this.exactListeners.has(event)) {
|
|
195
203
|
const list = this.exactListeners.get(event);
|
|
196
|
-
|
|
197
|
-
for (
|
|
204
|
+
let hasOnce = false;
|
|
205
|
+
for (let i = 0; i < list.length; i++) {
|
|
206
|
+
const item = list[i];
|
|
198
207
|
if (matchesSender(item.sender, sender)) {
|
|
199
208
|
matched.push({
|
|
200
209
|
priority: item.priority,
|
|
@@ -207,53 +216,67 @@ class EventDispatcher extends EventEmitter {
|
|
|
207
216
|
once: item.once,
|
|
208
217
|
});
|
|
209
218
|
}
|
|
210
|
-
if (
|
|
211
|
-
|
|
219
|
+
if (item.once) {
|
|
220
|
+
hasOnce = true;
|
|
212
221
|
}
|
|
213
222
|
}
|
|
214
|
-
|
|
223
|
+
if (hasOnce) {
|
|
224
|
+
this.exactListeners.set(event, list.filter((l) => !l.once));
|
|
225
|
+
}
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
// 2. Wildcard listeners
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
229
|
+
if (this.wildcardListeners.length > 0) {
|
|
230
|
+
let hasOnce = false;
|
|
231
|
+
for (let i = 0; i < this.wildcardListeners.length; i++) {
|
|
232
|
+
const item = this.wildcardListeners[i];
|
|
233
|
+
if (item.regex.test(event) && matchesSender(item.sender, sender)) {
|
|
234
|
+
matched.push({
|
|
235
|
+
priority: item.priority,
|
|
236
|
+
handler: item.handler,
|
|
237
|
+
isAny: false,
|
|
238
|
+
remove: () => {
|
|
239
|
+
this.wildcardListeners = this.wildcardListeners.filter((l) => l.id !== item.id);
|
|
240
|
+
},
|
|
241
|
+
once: item.once,
|
|
242
|
+
});
|
|
243
|
+
if (item.once) {
|
|
244
|
+
hasOnce = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (hasOnce) {
|
|
249
|
+
this.wildcardListeners = this.wildcardListeners.filter((l) => !l.once);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 3. Any listeners
|
|
254
|
+
if (this.anyListeners.length > 0) {
|
|
255
|
+
let hasOnce = false;
|
|
256
|
+
for (let i = 0; i < this.anyListeners.length; i++) {
|
|
257
|
+
const item = this.anyListeners[i];
|
|
221
258
|
matched.push({
|
|
222
259
|
priority: item.priority,
|
|
223
260
|
handler: item.handler,
|
|
224
|
-
isAny:
|
|
261
|
+
isAny: true,
|
|
225
262
|
remove: () => {
|
|
226
|
-
this.
|
|
263
|
+
this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
|
|
227
264
|
},
|
|
228
265
|
once: item.once,
|
|
229
266
|
});
|
|
267
|
+
if (item.once) {
|
|
268
|
+
hasOnce = true;
|
|
269
|
+
}
|
|
230
270
|
}
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
this.wildcardListeners = remainingWildcards;
|
|
236
|
-
|
|
237
|
-
// 3. Any listeners
|
|
238
|
-
const remainingAny = [];
|
|
239
|
-
for (const item of this.anyListeners) {
|
|
240
|
-
matched.push({
|
|
241
|
-
priority: item.priority,
|
|
242
|
-
handler: item.handler,
|
|
243
|
-
isAny: true,
|
|
244
|
-
remove: () => {
|
|
245
|
-
this.anyListeners = this.anyListeners.filter((l) => l.id !== item.id);
|
|
246
|
-
},
|
|
247
|
-
once: item.once,
|
|
248
|
-
});
|
|
249
|
-
if (!item.once) {
|
|
250
|
-
remainingAny.push(item);
|
|
271
|
+
if (hasOnce) {
|
|
272
|
+
this.anyListeners = this.anyListeners.filter((l) => !l.once);
|
|
251
273
|
}
|
|
252
274
|
}
|
|
253
|
-
this.anyListeners = remainingAny;
|
|
254
275
|
|
|
255
276
|
// Sort by priority descending (higher numbers first)
|
|
256
|
-
matched.
|
|
277
|
+
if (matched.length > 1) {
|
|
278
|
+
matched.sort((a, b) => b.priority - a.priority);
|
|
279
|
+
}
|
|
257
280
|
|
|
258
281
|
return matched;
|
|
259
282
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* ORM column → OpenAPI schema fragments (shared by schema-explorer and swagger plugin)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const modelModule = require('../orm/model');
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Generate OpenAPI 3 components.schemas from registered ORM models
|
|
@@ -10,8 +10,7 @@ const { getAllModels } = require('../orm/model');
|
|
|
10
10
|
* @returns {Record<string, object>}
|
|
11
11
|
*/
|
|
12
12
|
function generateOrmOpenApiSchemas(options = {}) {
|
|
13
|
-
const { exclude = [] } = options;
|
|
14
|
-
const models = getAllModels();
|
|
13
|
+
const { exclude = [], models = modelModule.getAllModels() } = options;
|
|
15
14
|
const schemas = {};
|
|
16
15
|
|
|
17
16
|
for (const [name, model] of models) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { ModelEvents, Hooks } = require('../events');
|
|
8
|
-
const
|
|
8
|
+
const modelModule = require('../model');
|
|
9
9
|
|
|
10
10
|
/** @type {Set<import('./layer').OrmCacheLayer>} */
|
|
11
11
|
const layers = new Set();
|
|
@@ -27,7 +27,7 @@ function registerOrmCacheListeners(cacheLayer) {
|
|
|
27
27
|
*/
|
|
28
28
|
function handleMutation(record, ctx, op) {
|
|
29
29
|
const modelName = ctx.model;
|
|
30
|
-
const model = getModel(modelName);
|
|
30
|
+
const model = modelModule.getModel(modelName);
|
|
31
31
|
if (!model) return;
|
|
32
32
|
|
|
33
33
|
for (const layer of layers) {
|
|
@@ -68,7 +68,17 @@ function unregisterOrmCacheListeners(cacheLayer) {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function _resetForTesting() {
|
|
72
|
+
for (const unsub of unsubs) {
|
|
73
|
+
if (typeof unsub === 'function') unsub();
|
|
74
|
+
}
|
|
75
|
+
unsubs = [];
|
|
76
|
+
layers.clear();
|
|
77
|
+
hooked = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
module.exports = {
|
|
72
81
|
registerOrmCacheListeners,
|
|
73
82
|
unregisterOrmCacheListeners,
|
|
83
|
+
_resetForTesting,
|
|
74
84
|
};
|
package/core/orm/json-fields.js
CHANGED
|
@@ -9,14 +9,20 @@
|
|
|
9
9
|
* @returns {Set<string>} Set of JSON column names
|
|
10
10
|
*/
|
|
11
11
|
function getJsonColumns(model) {
|
|
12
|
+
if (model && model._jsonColumns) {
|
|
13
|
+
return model._jsonColumns;
|
|
14
|
+
}
|
|
12
15
|
const jsonCols = new Set();
|
|
13
|
-
if (model.columns) {
|
|
16
|
+
if (model && model.columns) {
|
|
14
17
|
for (const [name, meta] of model.columns) {
|
|
15
18
|
if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
|
|
16
19
|
jsonCols.add(name);
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
}
|
|
23
|
+
if (model) {
|
|
24
|
+
model._jsonColumns = jsonCols;
|
|
25
|
+
}
|
|
20
26
|
return jsonCols;
|
|
21
27
|
}
|
|
22
28
|
|