webspresso 0.0.101 → 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/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/ORM.md +13 -4
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +99 -2
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +6 -0
- package/README.md +20 -7
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/routes.js +8 -0
- package/core/applySchema.js +18 -1
- 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 +178 -6
- package/index.js +71 -16
- package/package.json +6 -5
- package/plugins/admin-panel/index.js +12 -0
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +24 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- 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 +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +84 -8
- package/src/file-router.js +215 -97
- package/src/helpers.js +139 -26
- package/src/njk-frontmatter.js +17 -1
- package/src/plugin-manager.js +46 -13
- package/src/routing/route-table.js +15 -5
- package/src/server.js +86 -15
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/services/validator.js +57 -32
- package/src/template/precompile.js +247 -0
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
|
|
package/core/orm/model.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* @module core/orm/model
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const z = require('zod');
|
|
7
8
|
const { trimUrlPathSlashes } = require('../url-path-normalize');
|
|
8
|
-
const { extractColumnsFromSchema } = require('./schema-helpers');
|
|
9
|
+
const { createSchemaHelpers, extractColumnsFromSchema } = require('./schema-helpers');
|
|
9
10
|
const { ModelEvents, Hooks } = require('./events');
|
|
11
|
+
const zdb = z ? createSchemaHelpers(z) : null;
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Global model registry
|
|
@@ -34,6 +36,10 @@ function defineModel(options) {
|
|
|
34
36
|
queryLimits = {},
|
|
35
37
|
cache: cacheOption,
|
|
36
38
|
searchable,
|
|
39
|
+
timestamps: timestampsOpt,
|
|
40
|
+
softDelete: softDeleteOpt,
|
|
41
|
+
softDeletes: softDeletesOpt,
|
|
42
|
+
options: nestedOptions = {},
|
|
37
43
|
} = options;
|
|
38
44
|
|
|
39
45
|
// Validate required fields
|
|
@@ -43,15 +49,94 @@ function defineModel(options) {
|
|
|
43
49
|
if (!table || typeof table !== 'string') {
|
|
44
50
|
throw new Error('Model table is required and must be a string');
|
|
45
51
|
}
|
|
46
|
-
if (!schema || typeof schema.parse !== 'function') {
|
|
52
|
+
if (!schema || typeof schema !== 'object' || (typeof schema.parse !== 'function' && Object.keys(schema).length === 0)) {
|
|
47
53
|
throw new Error('Model schema is required and must be a Zod schema');
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
// Resolve timestamps and softDelete options (from top-level flags, nested options, or scopes)
|
|
57
|
+
const timestampsConfig = timestampsOpt !== undefined
|
|
58
|
+
? timestampsOpt
|
|
59
|
+
: (nestedOptions.timestamps !== undefined ? nestedOptions.timestamps : scopes.timestamps);
|
|
60
|
+
|
|
61
|
+
const softDeleteConfig = softDeleteOpt !== undefined
|
|
62
|
+
? softDeleteOpt
|
|
63
|
+
: (softDeletesOpt !== undefined
|
|
64
|
+
? softDeletesOpt
|
|
65
|
+
: (nestedOptions.softDelete !== undefined
|
|
66
|
+
? nestedOptions.softDelete
|
|
67
|
+
: (nestedOptions.softDeletes !== undefined
|
|
68
|
+
? nestedOptions.softDeletes
|
|
69
|
+
: scopes.softDelete)));
|
|
70
|
+
|
|
71
|
+
const isPlainObject = typeof schema.parse !== 'function';
|
|
72
|
+
const injectedFields = {};
|
|
73
|
+
|
|
74
|
+
if (timestampsConfig) {
|
|
75
|
+
const isSnake = typeof timestampsConfig === 'object'
|
|
76
|
+
? Boolean(timestampsConfig.snakeCase || timestampsConfig.format === 'snake')
|
|
77
|
+
: (timestampsConfig === 'snake');
|
|
78
|
+
const createdCol = (typeof timestampsConfig === 'object' && timestampsConfig.createdAt)
|
|
79
|
+
|| (isSnake ? 'created_at' : 'createdAt');
|
|
80
|
+
const updatedCol = (typeof timestampsConfig === 'object' && timestampsConfig.updatedAt)
|
|
81
|
+
|| (isSnake ? 'updated_at' : 'updatedAt');
|
|
82
|
+
|
|
83
|
+
const hasCreated = isPlainObject
|
|
84
|
+
? (schema[createdCol] !== undefined || schema.created_at !== undefined || schema.createdAt !== undefined)
|
|
85
|
+
: (schema.shape && (schema.shape[createdCol] !== undefined || schema.shape.created_at !== undefined || schema.shape.createdAt !== undefined));
|
|
86
|
+
const hasUpdated = isPlainObject
|
|
87
|
+
? (schema[updatedCol] !== undefined || schema.updated_at !== undefined || schema.updatedAt !== undefined)
|
|
88
|
+
: (schema.shape && (schema.shape[updatedCol] !== undefined || schema.shape.updated_at !== undefined || schema.shape.updatedAt !== undefined));
|
|
89
|
+
|
|
90
|
+
if (!hasCreated && zdb) {
|
|
91
|
+
injectedFields[createdCol] = zdb.timestamp({ auto: 'create' });
|
|
92
|
+
}
|
|
93
|
+
if (!hasUpdated && zdb) {
|
|
94
|
+
injectedFields[updatedCol] = zdb.timestamp({ auto: 'update' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (softDeleteConfig) {
|
|
99
|
+
const isSnake = typeof softDeleteConfig === 'object'
|
|
100
|
+
? Boolean(softDeleteConfig.snakeCase || softDeleteConfig.format === 'snake')
|
|
101
|
+
: (softDeleteConfig === 'snake');
|
|
102
|
+
const deletedCol = (typeof softDeleteConfig === 'object' && softDeleteConfig.deletedAt)
|
|
103
|
+
|| (isSnake ? 'deleted_at' : 'deletedAt');
|
|
104
|
+
|
|
105
|
+
const hasDeleted = isPlainObject
|
|
106
|
+
? (schema[deletedCol] !== undefined || schema.deleted_at !== undefined || schema.deletedAt !== undefined)
|
|
107
|
+
: (schema.shape && (schema.shape[deletedCol] !== undefined || schema.shape.deleted_at !== undefined || schema.shape.deletedAt !== undefined));
|
|
108
|
+
|
|
109
|
+
if (!hasDeleted && zdb) {
|
|
110
|
+
injectedFields[deletedCol] = zdb.softDelete();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let normalizedSchema;
|
|
115
|
+
if (isPlainObject) {
|
|
116
|
+
if (!zdb) {
|
|
117
|
+
throw new Error('Zod is required to compile schema object');
|
|
118
|
+
}
|
|
119
|
+
normalizedSchema = zdb.schema({
|
|
120
|
+
...schema,
|
|
121
|
+
...injectedFields,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
normalizedSchema = schema;
|
|
125
|
+
if (Object.keys(injectedFields).length > 0 && typeof normalizedSchema.extend === 'function') {
|
|
126
|
+
const finalizedInjected = {};
|
|
127
|
+
for (const [key, field] of Object.entries(injectedFields)) {
|
|
128
|
+
finalizedInjected[key] = (field && typeof field._finalize === 'function')
|
|
129
|
+
? field._finalize()
|
|
130
|
+
: field;
|
|
131
|
+
}
|
|
132
|
+
normalizedSchema = normalizedSchema.extend(finalizedInjected);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (normalizedSchema && normalizedSchema.shape) {
|
|
137
|
+
for (const [key, val] of Object.entries(normalizedSchema.shape)) {
|
|
53
138
|
if (val && typeof val._finalize === 'function') {
|
|
54
|
-
|
|
139
|
+
normalizedSchema.shape[key] = val._finalize();
|
|
55
140
|
}
|
|
56
141
|
}
|
|
57
142
|
}
|
|
@@ -105,13 +190,19 @@ function defineModel(options) {
|
|
|
105
190
|
name,
|
|
106
191
|
table,
|
|
107
192
|
schema: normalizedSchema,
|
|
193
|
+
_partialSchema: typeof normalizedSchema?.partial === 'function' ? normalizedSchema.partial() : normalizedSchema,
|
|
108
194
|
primaryKey,
|
|
109
195
|
relations: normalizedRelations,
|
|
110
196
|
scopes: {
|
|
111
|
-
softDelete:
|
|
112
|
-
timestamps:
|
|
197
|
+
softDelete: Boolean(softDeleteConfig),
|
|
198
|
+
timestamps: Boolean(timestampsConfig),
|
|
113
199
|
tenant: scopes.tenant || null,
|
|
114
200
|
},
|
|
201
|
+
options: {
|
|
202
|
+
timestamps: timestampsConfig || false,
|
|
203
|
+
softDelete: softDeleteConfig || false,
|
|
204
|
+
...nestedOptions,
|
|
205
|
+
},
|
|
115
206
|
columns,
|
|
116
207
|
admin: {
|
|
117
208
|
enabled: admin.enabled === true, // Explicit boolean check
|