webspresso 0.0.102 → 0.0.104
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/bin/commands/dev.js +3 -2
- package/bin/utils/orm-map-html.js +2 -8
- package/core/auth/jwt.js +12 -21
- package/core/auth/oauth.js +38 -14
- package/core/auth/totp.js +3 -15
- package/core/auth/user-agent.js +59 -60
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/content/renderer.js +1 -13
- package/core/errors/middleware.js +2 -8
- package/core/events/dispatcher.js +56 -33
- package/core/forms/form-builder.js +1 -9
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/index.js +5 -0
- 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/query-filters.js +393 -0
- 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/shutdown/shutdown-manager.js +16 -16
- package/core/utils/crypto.js +73 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/fetch.js +120 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/safe-compare.js +3 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +33 -6
- package/index.js +10 -0
- package/package.json +2 -1
- package/plugins/admin-panel/auth.js +15 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
- package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
- package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
- package/plugins/admin-panel/core/api-extensions.js +1 -75
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/api-key/crypto.js +1 -24
- package/plugins/audit-log/api-handlers.js +11 -1
- package/plugins/audit-log/middleware.js +50 -6
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +3 -15
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/core.js +34 -4
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/index.js +23 -1
- package/plugins/mcp/transports/sse.js +10 -1
- package/plugins/orm-cache-admin/admin-component.js +3 -2
- package/plugins/polar/src/api.js +9 -2
- package/plugins/polar/src/webhooks.js +3 -8
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +51 -3
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/schema-explorer/admin-component.js +4 -2
- package/plugins/schema-explorer.js +40 -29
- package/plugins/seo-checker/index.js +5 -2
- 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 +265 -63
- package/plugins/sitemap.js +18 -15
- package/plugins/swagger.js +9 -14
- package/plugins/webhooks/crypto.js +1 -18
- package/plugins/webhooks/dispatcher.js +6 -1
- package/plugins/webhooks/matcher.js +25 -10
- package/plugins/webhooks/sender.js +32 -2
- package/src/api/api-loader.js +1 -1
- package/src/dev-reload/index.js +47 -6
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +157 -32
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +25 -9
- package/src/services/validator.js +57 -32
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { normalizeError, toErrorResponseObject } = require('./normalize');
|
|
7
7
|
const { RequestAbortedError } = require('./domain');
|
|
8
|
+
const { escapeHtml } = require('../utils/html');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Check if the request prefers a JSON error response
|
|
@@ -60,14 +61,7 @@ function renderDefaultErrorHtml(err, isDev) {
|
|
|
60
61
|
</html>`;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
return String(str || '')
|
|
65
|
-
.replace(/&/g, '&')
|
|
66
|
-
.replace(/</g, '<')
|
|
67
|
-
.replace(/>/g, '>')
|
|
68
|
-
.replace(/"/g, '"')
|
|
69
|
-
.replace(/'/g, ''');
|
|
70
|
-
}
|
|
64
|
+
|
|
71
65
|
|
|
72
66
|
/**
|
|
73
67
|
* Create Webspresso Central Error Handler Middleware
|
|
@@ -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
|
}
|
|
@@ -4,15 +4,7 @@
|
|
|
4
4
|
* @module core/forms/form-builder
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
if (str === null || str === undefined) return '';
|
|
9
|
-
return String(str)
|
|
10
|
-
.replace(/&/g, '&')
|
|
11
|
-
.replace(/</g, '<')
|
|
12
|
-
.replace(/>/g, '>')
|
|
13
|
-
.replace(/"/g, '"')
|
|
14
|
-
.replace(/'/g, ''');
|
|
15
|
-
}
|
|
7
|
+
const { escapeHtml } = require('../utils/html');
|
|
16
8
|
|
|
17
9
|
/**
|
|
18
10
|
* Render a single form field with label, input, and validation error message
|
|
@@ -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/index.js
CHANGED
|
@@ -392,4 +392,9 @@ module.exports = {
|
|
|
392
392
|
// Schema Diff & Makemigrations (Django-inspired)
|
|
393
393
|
diffSchema: require('./migrations/diff').diffSchema,
|
|
394
394
|
makeMigrations: require('./migrations/diff').makeMigrations,
|
|
395
|
+
// Universal Query Filters (REST Resources & Admin Panel)
|
|
396
|
+
buildFilteredQuery: require('./query-filters').buildFilteredQuery,
|
|
397
|
+
applyColumnFilters: require('./query-filters').applyColumnFilters,
|
|
398
|
+
applyFilterCondition: require('./query-filters').applyFilterCondition,
|
|
399
|
+
queryFilters: require('./query-filters'),
|
|
395
400
|
};
|
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
|
|
@@ -42,7 +42,7 @@ class QueryBuilder {
|
|
|
42
42
|
constructor(model, knex, initialContext, cacheLayer = null) {
|
|
43
43
|
this.model = model;
|
|
44
44
|
this.knex = knex;
|
|
45
|
-
this.scopeContext = initialContext
|
|
45
|
+
this.scopeContext = initialContext ? { ...initialContext } : createScopeContext();
|
|
46
46
|
this.cacheLayer = cacheLayer;
|
|
47
47
|
this.jsonColumns = getJsonColumns(model);
|
|
48
48
|
|
|
@@ -78,11 +78,11 @@ class QueryBuilder {
|
|
|
78
78
|
where(columnOrConditions, operatorOrValue, value) {
|
|
79
79
|
// Object form: where({ column: value, ... })
|
|
80
80
|
if (typeof columnOrConditions === 'object' && columnOrConditions !== null) {
|
|
81
|
-
for (const
|
|
81
|
+
for (const col of Object.keys(columnOrConditions)) {
|
|
82
82
|
this.state.wheres.push({
|
|
83
83
|
column: col,
|
|
84
84
|
operator: '=',
|
|
85
|
-
value:
|
|
85
|
+
value: columnOrConditions[col],
|
|
86
86
|
boolean: 'and',
|
|
87
87
|
});
|
|
88
88
|
}
|
|
@@ -176,6 +176,22 @@ class QueryBuilder {
|
|
|
176
176
|
return this;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Add a WHERE NOT clause (col != val or multiple columns)
|
|
181
|
+
* @param {string|Object} column - Column name or object of { col: val }
|
|
182
|
+
* @param {*} [value] - Value to compare against
|
|
183
|
+
* @returns {this}
|
|
184
|
+
*/
|
|
185
|
+
whereNot(column, value) {
|
|
186
|
+
if (typeof column === 'object' && column !== null) {
|
|
187
|
+
for (const [col, val] of Object.entries(column)) {
|
|
188
|
+
this.where(col, '!=', val);
|
|
189
|
+
}
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
return this.where(column, '!=', value);
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
/**
|
|
180
196
|
* Add a WHERE NOT IN clause
|
|
181
197
|
* @param {string} column - Column name
|
|
@@ -477,27 +493,37 @@ class QueryBuilder {
|
|
|
477
493
|
* @returns {Promise<Object|null>}
|
|
478
494
|
*/
|
|
479
495
|
async first() {
|
|
480
|
-
const
|
|
496
|
+
const shouldEmit = (
|
|
497
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
498
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
499
|
+
);
|
|
500
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
481
501
|
const self = this;
|
|
482
502
|
|
|
483
503
|
async function loadFromDb() {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
504
|
+
if (shouldEmit) {
|
|
505
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
506
|
+
if (ctx.isCancelled) {
|
|
507
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
508
|
+
}
|
|
487
509
|
}
|
|
488
510
|
|
|
489
511
|
const qb = self.toKnex().first();
|
|
490
512
|
const result = await qb;
|
|
491
513
|
if (!result) return null;
|
|
492
514
|
|
|
493
|
-
|
|
515
|
+
if (self.jsonColumns.size > 0) {
|
|
516
|
+
deserializeJsonFields(result, self.jsonColumns);
|
|
517
|
+
}
|
|
494
518
|
|
|
495
519
|
const withs = self.getWiths();
|
|
496
520
|
if (withs.length > 0) {
|
|
497
521
|
await loadRelations([result], ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
498
522
|
}
|
|
499
523
|
|
|
500
|
-
|
|
524
|
+
if (shouldEmit) {
|
|
525
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, result, ctx);
|
|
526
|
+
}
|
|
501
527
|
return result;
|
|
502
528
|
}
|
|
503
529
|
|
|
@@ -543,19 +569,27 @@ class QueryBuilder {
|
|
|
543
569
|
* @returns {Promise<Object[]>}
|
|
544
570
|
*/
|
|
545
571
|
async list() {
|
|
546
|
-
const
|
|
572
|
+
const shouldEmit = (
|
|
573
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
574
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
575
|
+
);
|
|
576
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
547
577
|
const self = this;
|
|
548
578
|
|
|
549
579
|
async function loadFromDb() {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
580
|
+
if (shouldEmit) {
|
|
581
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
582
|
+
if (ctx.isCancelled) {
|
|
583
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
584
|
+
}
|
|
553
585
|
}
|
|
554
586
|
|
|
555
587
|
const results = await self.toKnex();
|
|
556
588
|
|
|
557
|
-
|
|
558
|
-
|
|
589
|
+
if (self.jsonColumns.size > 0) {
|
|
590
|
+
for (const record of results) {
|
|
591
|
+
deserializeJsonFields(record, self.jsonColumns);
|
|
592
|
+
}
|
|
559
593
|
}
|
|
560
594
|
|
|
561
595
|
const withs = self.getWiths();
|
|
@@ -563,8 +597,10 @@ class QueryBuilder {
|
|
|
563
597
|
await loadRelations(results, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
564
598
|
}
|
|
565
599
|
|
|
566
|
-
|
|
567
|
-
|
|
600
|
+
if (shouldEmit) {
|
|
601
|
+
for (const record of results) {
|
|
602
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
603
|
+
}
|
|
568
604
|
}
|
|
569
605
|
return results;
|
|
570
606
|
}
|
|
@@ -676,13 +712,19 @@ class QueryBuilder {
|
|
|
676
712
|
perPage = validated.perPage;
|
|
677
713
|
page = Math.max(1, parseInt(page, 10) || 1);
|
|
678
714
|
|
|
679
|
-
const
|
|
715
|
+
const shouldEmit = (
|
|
716
|
+
ModelEvents.hasListeners(this.model.name, Hooks.BEFORE_FIND) ||
|
|
717
|
+
ModelEvents.hasListeners(this.model.name, Hooks.AFTER_FIND)
|
|
718
|
+
);
|
|
719
|
+
const ctx = shouldEmit ? createEventContext(this.model.name, 'find', this._hookTrx()) : null;
|
|
680
720
|
const self = this;
|
|
681
721
|
|
|
682
722
|
async function loadFromDb() {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
723
|
+
if (shouldEmit) {
|
|
724
|
+
await ModelEvents.emitAsync(self.model.name, Hooks.BEFORE_FIND, {}, ctx);
|
|
725
|
+
if (ctx.isCancelled) {
|
|
726
|
+
throw new HookCancellationError(ctx.cancelReason, self.model.name, Hooks.BEFORE_FIND);
|
|
727
|
+
}
|
|
686
728
|
}
|
|
687
729
|
|
|
688
730
|
const base = self.toKnex({ includeLimitOffset: false });
|
|
@@ -693,8 +735,10 @@ class QueryBuilder {
|
|
|
693
735
|
const offset = (page - 1) * perPage;
|
|
694
736
|
const data = await base.clone().limit(perPage).offset(offset);
|
|
695
737
|
|
|
696
|
-
|
|
697
|
-
|
|
738
|
+
if (self.jsonColumns.size > 0) {
|
|
739
|
+
for (const record of data) {
|
|
740
|
+
deserializeJsonFields(record, self.jsonColumns);
|
|
741
|
+
}
|
|
698
742
|
}
|
|
699
743
|
|
|
700
744
|
const withs = self.getWiths();
|
|
@@ -702,8 +746,10 @@ class QueryBuilder {
|
|
|
702
746
|
await loadRelations(data, ensureArray(withs), self.model, self.getActiveKnex(), self.scopeContext);
|
|
703
747
|
}
|
|
704
748
|
|
|
705
|
-
|
|
706
|
-
|
|
749
|
+
if (shouldEmit) {
|
|
750
|
+
for (const record of data) {
|
|
751
|
+
ModelEvents.emit(self.model.name, Hooks.AFTER_FIND, record, ctx);
|
|
752
|
+
}
|
|
707
753
|
}
|
|
708
754
|
|
|
709
755
|
return {
|