webspresso 0.0.97 → 0.0.99
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/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Build OpenAPI 3 document from file-router API route metadata + Zod schemas
|
|
5
|
+
* @module core/openapi/build-from-api-routes
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
const fs = require('fs');
|
|
@@ -16,11 +19,81 @@ const OPENAPI_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head'
|
|
|
16
19
|
* @returns {string}
|
|
17
20
|
*/
|
|
18
21
|
function expressPathToOpenApi(expressPath) {
|
|
22
|
+
if (!expressPath) return '';
|
|
19
23
|
return String(expressPath)
|
|
20
24
|
.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
|
|
25
|
+
.replace(/\[([A-Za-z0-9_]+)\]/g, '{$1}')
|
|
21
26
|
.replace(/\*/g, '{wildcard}');
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the physical file path of an API route descriptor
|
|
31
|
+
* @param {Object} route
|
|
32
|
+
* @param {string} [pagesDir]
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
function resolveRouteFilePath(route, pagesDir) {
|
|
36
|
+
const rawFile = route.file || route.source;
|
|
37
|
+
if (!rawFile) return null;
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(rawFile) && fs.existsSync(rawFile)) {
|
|
40
|
+
return rawFile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fromCwd = path.resolve(process.cwd(), rawFile);
|
|
44
|
+
if (fs.existsSync(fromCwd)) {
|
|
45
|
+
return fromCwd;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (pagesDir) {
|
|
49
|
+
const fromPages = path.join(path.resolve(pagesDir), rawFile);
|
|
50
|
+
if (fs.existsSync(fromPages)) {
|
|
51
|
+
return fromPages;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Infers an expressive operation summary from HTTP method and path
|
|
60
|
+
* @param {string} method
|
|
61
|
+
* @param {string} routePath
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function inferOperationSummary(method, routePath) {
|
|
65
|
+
const clean = String(routePath || '').replace(/^\/api\/?/, '').replace(/\/$/, '');
|
|
66
|
+
const segments = clean.split('/').filter(Boolean);
|
|
67
|
+
const lastSeg = segments[segments.length - 1] || 'root';
|
|
68
|
+
const isParam = lastSeg.startsWith(':') || (lastSeg.startsWith('{') && lastSeg.endsWith('}')) || (lastSeg.startsWith('[') && lastSeg.endsWith(']'));
|
|
69
|
+
const resource = isParam && segments.length > 1 ? segments[segments.length - 2] : lastSeg;
|
|
70
|
+
const m = String(method || 'GET').toUpperCase();
|
|
71
|
+
|
|
72
|
+
if (m === 'GET') {
|
|
73
|
+
return isParam ? `Get ${resource} by ID` : `List ${resource}`;
|
|
74
|
+
}
|
|
75
|
+
if (m === 'POST') return `Create ${resource}`;
|
|
76
|
+
if (m === 'PUT' || m === 'PATCH') return `Update ${resource}`;
|
|
77
|
+
if (m === 'DELETE') return `Delete ${resource}`;
|
|
78
|
+
return `${m} /api/${clean}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Infers tag category from route path segments
|
|
83
|
+
* @param {string} routePath
|
|
84
|
+
* @param {string[]} [explicitTags]
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function inferOperationTags(routePath, explicitTags) {
|
|
88
|
+
if (Array.isArray(explicitTags) && explicitTags.length > 0) return explicitTags;
|
|
89
|
+
const clean = String(routePath || '').replace(/^\/api\/?/, '').replace(/\/$/, '');
|
|
90
|
+
const firstSeg = clean.split('/').filter(Boolean)[0];
|
|
91
|
+
if (firstSeg && !firstSeg.startsWith(':') && !firstSeg.startsWith('{') && !firstSeg.startsWith('[')) {
|
|
92
|
+
return [firstSeg];
|
|
93
|
+
}
|
|
94
|
+
return ['api'];
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
/**
|
|
25
98
|
* @param {import('zod').ZodObject<any>} zodObj
|
|
26
99
|
* @param {'path' | 'query'} where
|
|
@@ -38,19 +111,31 @@ function expandZodObjectToParameters(zodObj, where) {
|
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
/**
|
|
41
|
-
* @param {object} route - { type, method, pattern, file }
|
|
114
|
+
* @param {object} route - { type, method, pattern, path, file }
|
|
42
115
|
* @param {object|null} compiled - compileSchema result
|
|
116
|
+
* @param {object|null} [mod] - Raw route module
|
|
43
117
|
* @returns {object} OpenAPI Operation Object
|
|
44
118
|
*/
|
|
45
|
-
function buildOperation(route, compiled) {
|
|
46
|
-
const filePath = route.file;
|
|
47
|
-
const method = route.method.toLowerCase();
|
|
48
|
-
const
|
|
119
|
+
function buildOperation(route, compiled, mod = null) {
|
|
120
|
+
const filePath = route.file || route.source || '';
|
|
121
|
+
const method = (route.method || 'GET').toLowerCase();
|
|
122
|
+
const routePath = route.path || route.pattern || filePath;
|
|
123
|
+
|
|
124
|
+
let summary = (mod && (mod.summary || mod.openapi?.summary)) || route.summary;
|
|
125
|
+
if (!summary) {
|
|
126
|
+
if (filePath) {
|
|
127
|
+
summary = `${method.toUpperCase()} ${filePath}`;
|
|
128
|
+
} else {
|
|
129
|
+
summary = inferOperationSummary(method, routePath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tags = inferOperationTags(routePath, (mod && (mod.tags || mod.openapi?.tags)) || route.tags);
|
|
49
134
|
|
|
50
135
|
const op = {
|
|
51
|
-
tags
|
|
136
|
+
tags,
|
|
52
137
|
summary,
|
|
53
|
-
operationId: String(filePath)
|
|
138
|
+
operationId: String(filePath || routePath)
|
|
54
139
|
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
55
140
|
.replace(/^_|_$/g, ''),
|
|
56
141
|
responses: {
|
|
@@ -60,6 +145,25 @@ function buildOperation(route, compiled) {
|
|
|
60
145
|
},
|
|
61
146
|
};
|
|
62
147
|
|
|
148
|
+
if (mod && mod.description) {
|
|
149
|
+
op.description = mod.description;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Auth / Security detection
|
|
153
|
+
const middlewares = Array.isArray(route.middleware)
|
|
154
|
+
? route.middleware
|
|
155
|
+
: (Array.isArray(mod?.middleware) ? mod.middleware : []);
|
|
156
|
+
const security = [];
|
|
157
|
+
if (middlewares.includes('jwt') || middlewares.includes('bearer')) {
|
|
158
|
+
security.push({ bearerAuth: [] });
|
|
159
|
+
}
|
|
160
|
+
if (middlewares.includes('auth') || middlewares.includes('session')) {
|
|
161
|
+
security.push({ sessionAuth: [] });
|
|
162
|
+
}
|
|
163
|
+
if (security.length > 0) {
|
|
164
|
+
op.security = security;
|
|
165
|
+
}
|
|
166
|
+
|
|
63
167
|
if (compiled?.response) {
|
|
64
168
|
op.responses['200'] = {
|
|
65
169
|
description: 'OK',
|
|
@@ -94,16 +198,17 @@ function buildOperation(route, compiled) {
|
|
|
94
198
|
}
|
|
95
199
|
|
|
96
200
|
/**
|
|
201
|
+
* Builds an OpenAPI 3.0 document from application routes and ORM components
|
|
97
202
|
* @param {object} opts
|
|
98
|
-
* @param {object[]} opts.routes -
|
|
99
|
-
* @param {string} opts.pagesDir
|
|
100
|
-
* @param {boolean} [opts.includeOrmSchemas]
|
|
101
|
-
* @param {string[]} [opts.ormExclude]
|
|
203
|
+
* @param {object[]} opts.routes - Route metadata array
|
|
204
|
+
* @param {string} opts.pagesDir - Pages directory path
|
|
205
|
+
* @param {boolean} [opts.includeOrmSchemas=false]
|
|
206
|
+
* @param {string[]} [opts.ormExclude=[]]
|
|
102
207
|
* @param {object} [opts.info]
|
|
103
208
|
* @param {object[]} [opts.servers]
|
|
104
209
|
* @returns {object} OpenAPI 3.0.x document
|
|
105
210
|
*/
|
|
106
|
-
function buildOpenApiDocument(opts) {
|
|
211
|
+
function buildOpenApiDocument(opts = {}) {
|
|
107
212
|
const {
|
|
108
213
|
routes = [],
|
|
109
214
|
pagesDir,
|
|
@@ -118,31 +223,39 @@ function buildOpenApiDocument(opts) {
|
|
|
118
223
|
}
|
|
119
224
|
|
|
120
225
|
const paths = Object.create(null);
|
|
121
|
-
const apiRoutes = routes.filter((r) => r.type === 'api');
|
|
122
|
-
const absPages = path.resolve(pagesDir);
|
|
226
|
+
const apiRoutes = routes.filter((r) => r.type === 'api' || (r.path && r.path.startsWith('/api')));
|
|
123
227
|
|
|
124
228
|
for (const route of apiRoutes) {
|
|
125
|
-
const fullPath =
|
|
126
|
-
if (!fs.existsSync(fullPath)) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
229
|
+
const fullPath = resolveRouteFilePath(route, pagesDir);
|
|
129
230
|
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
mod = require(fullPath);
|
|
133
|
-
} catch {
|
|
231
|
+
if (route.file && !fullPath) {
|
|
134
232
|
continue;
|
|
135
233
|
}
|
|
136
234
|
|
|
235
|
+
let mod = null;
|
|
137
236
|
let compiled = null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
237
|
+
|
|
238
|
+
if (fullPath) {
|
|
239
|
+
try {
|
|
240
|
+
mod = require(fullPath);
|
|
241
|
+
} catch {
|
|
242
|
+
mod = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (mod) {
|
|
246
|
+
try {
|
|
247
|
+
compiled = compileSchema(fullPath, mod);
|
|
248
|
+
} catch {
|
|
249
|
+
compiled = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
142
252
|
}
|
|
143
253
|
|
|
144
|
-
const
|
|
145
|
-
|
|
254
|
+
const routePattern = route.path || route.pattern || '';
|
|
255
|
+
if (!routePattern) continue;
|
|
256
|
+
|
|
257
|
+
const openApiPath = expressPathToOpenApi(routePattern);
|
|
258
|
+
const method = (route.method || 'GET').toLowerCase();
|
|
146
259
|
|
|
147
260
|
if (!OPENAPI_METHODS.has(method)) {
|
|
148
261
|
continue;
|
|
@@ -152,7 +265,7 @@ function buildOpenApiDocument(opts) {
|
|
|
152
265
|
paths[openApiPath] = Object.create(null);
|
|
153
266
|
}
|
|
154
267
|
|
|
155
|
-
paths[openApiPath][method] = buildOperation(route, compiled);
|
|
268
|
+
paths[openApiPath][method] = buildOperation(route, compiled, mod);
|
|
156
269
|
}
|
|
157
270
|
|
|
158
271
|
const doc = {
|
|
@@ -165,6 +278,20 @@ function buildOpenApiDocument(opts) {
|
|
|
165
278
|
servers,
|
|
166
279
|
paths,
|
|
167
280
|
components: {
|
|
281
|
+
securitySchemes: {
|
|
282
|
+
bearerAuth: {
|
|
283
|
+
type: 'http',
|
|
284
|
+
scheme: 'bearer',
|
|
285
|
+
bearerFormat: 'JWT',
|
|
286
|
+
description: 'Stateless JWT Bearer token authentication',
|
|
287
|
+
},
|
|
288
|
+
sessionAuth: {
|
|
289
|
+
type: 'apiKey',
|
|
290
|
+
in: 'cookie',
|
|
291
|
+
name: 'connect.sid',
|
|
292
|
+
description: 'Session cookie authentication',
|
|
293
|
+
},
|
|
294
|
+
},
|
|
168
295
|
schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
|
|
169
296
|
},
|
|
170
297
|
};
|
|
@@ -174,5 +301,9 @@ function buildOpenApiDocument(opts) {
|
|
|
174
301
|
|
|
175
302
|
module.exports = {
|
|
176
303
|
expressPathToOpenApi,
|
|
304
|
+
resolveRouteFilePath,
|
|
305
|
+
inferOperationSummary,
|
|
306
|
+
inferOperationTags,
|
|
307
|
+
buildOperation,
|
|
177
308
|
buildOpenApiDocument,
|
|
178
309
|
};
|
|
@@ -28,7 +28,21 @@ function stableValue(v) {
|
|
|
28
28
|
const k = keys[0];
|
|
29
29
|
return { [k]: stableValue(v[k]) };
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
if (keyLen === 2) {
|
|
32
|
+
const k0 = keys[0];
|
|
33
|
+
const k1 = keys[1];
|
|
34
|
+
return k0 < k1
|
|
35
|
+
? { [k0]: stableValue(v[k0]), [k1]: stableValue(v[k1]) }
|
|
36
|
+
: { [k1]: stableValue(v[k1]), [k0]: stableValue(v[k0]) };
|
|
37
|
+
}
|
|
38
|
+
let isSorted = true;
|
|
39
|
+
for (let i = 1; i < keyLen; i++) {
|
|
40
|
+
if (keys[i - 1] > keys[i]) {
|
|
41
|
+
isSorted = false;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!isSorted) keys.sort();
|
|
32
46
|
const out = {};
|
|
33
47
|
for (let i = 0; i < keyLen; i++) {
|
|
34
48
|
const k = keys[i];
|
|
@@ -56,8 +56,20 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
56
56
|
if (store.has(key)) removeKey(key);
|
|
57
57
|
|
|
58
58
|
if (maxEntries > 0 && store.size >= maxEntries) {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// Active sweep: evict expired entries before falling back to FIFO eviction
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
let freed = 0;
|
|
62
|
+
for (const [k, entry] of store.entries()) {
|
|
63
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
64
|
+
removeKey(k);
|
|
65
|
+
freed++;
|
|
66
|
+
if (freed >= 10 || store.size < maxEntries) break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (store.size >= maxEntries) {
|
|
70
|
+
const first = store.keys().next().value;
|
|
71
|
+
if (first !== undefined) removeKey(first);
|
|
72
|
+
}
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
|
|
@@ -82,6 +94,18 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
82
94
|
for (const t of tags) tagToKeys.delete(t);
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
function pruneExpired() {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
let count = 0;
|
|
100
|
+
for (const [k, entry] of store.entries()) {
|
|
101
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
102
|
+
removeKey(k);
|
|
103
|
+
count++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return count;
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
function clear() {
|
|
86
110
|
store.clear();
|
|
87
111
|
tagToKeys.clear();
|
|
@@ -99,6 +123,7 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
99
123
|
get,
|
|
100
124
|
set,
|
|
101
125
|
invalidateTags,
|
|
126
|
+
pruneExpired,
|
|
102
127
|
clear,
|
|
103
128
|
getSizeStats,
|
|
104
129
|
};
|
package/core/orm/eager-loader.js
CHANGED
|
@@ -1,31 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Webspresso ORM - Eager Loader
|
|
3
|
-
* Batch loading algorithm for relations
|
|
3
|
+
* Batch loading algorithm for relations (supports direct & arbitrary nested relations)
|
|
4
4
|
* @module core/orm/eager-loader
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { resolveRelationModel, getRelationKeys } = require('./model');
|
|
8
8
|
const { applyScopes, createScopeContext } = require('./scopes');
|
|
9
|
+
const { getJsonColumns, deserializeJsonFields } = require('./json-fields');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
+
* Parse relation specifications into a grouped structure: Map<topLevelRelation, string[]>
|
|
13
|
+
* Supports dot notation ('author.profile'), arrays (['author', 'comments.author']),
|
|
14
|
+
* and nested relation chains of arbitrary depth.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<string|Object>} relationNames
|
|
17
|
+
* @returns {Map<string, string[]>}
|
|
18
|
+
*/
|
|
19
|
+
function parseRelationTree(relationNames) {
|
|
20
|
+
const tree = new Map();
|
|
21
|
+
|
|
22
|
+
function addPath(top, nested) {
|
|
23
|
+
if (!top || typeof top !== 'string') return;
|
|
24
|
+
const cleanTop = top.trim();
|
|
25
|
+
if (!cleanTop) return;
|
|
26
|
+
|
|
27
|
+
if (!tree.has(cleanTop)) {
|
|
28
|
+
tree.set(cleanTop, []);
|
|
29
|
+
}
|
|
30
|
+
if (nested) {
|
|
31
|
+
if (Array.isArray(nested)) {
|
|
32
|
+
for (const item of nested) {
|
|
33
|
+
if (item && typeof item === 'string') tree.get(cleanTop).push(item.trim());
|
|
34
|
+
}
|
|
35
|
+
} else if (typeof nested === 'string') {
|
|
36
|
+
const cleanNested = nested.trim();
|
|
37
|
+
if (cleanNested) tree.get(cleanTop).push(cleanNested);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function processItem(item) {
|
|
43
|
+
if (!item) return;
|
|
44
|
+
|
|
45
|
+
if (typeof item === 'string') {
|
|
46
|
+
const dotIndex = item.indexOf('.');
|
|
47
|
+
if (dotIndex === -1) {
|
|
48
|
+
addPath(item);
|
|
49
|
+
} else {
|
|
50
|
+
const top = item.slice(0, dotIndex);
|
|
51
|
+
const rest = item.slice(dotIndex + 1);
|
|
52
|
+
addPath(top, rest);
|
|
53
|
+
}
|
|
54
|
+
} else if (Array.isArray(item)) {
|
|
55
|
+
for (const sub of item) {
|
|
56
|
+
processItem(sub);
|
|
57
|
+
}
|
|
58
|
+
} else if (typeof item === 'object') {
|
|
59
|
+
for (const [key, value] of Object.entries(item)) {
|
|
60
|
+
if (value === true || value == null) {
|
|
61
|
+
processItem(key);
|
|
62
|
+
} else if (typeof value === 'string' || Array.isArray(value)) {
|
|
63
|
+
const dotIndex = key.indexOf('.');
|
|
64
|
+
if (dotIndex === -1) {
|
|
65
|
+
addPath(key, value);
|
|
66
|
+
} else {
|
|
67
|
+
const top = key.slice(0, dotIndex);
|
|
68
|
+
const rest = key.slice(dotIndex + 1);
|
|
69
|
+
const nestedValues = Array.isArray(value) ? value : [value];
|
|
70
|
+
for (const v of nestedValues) {
|
|
71
|
+
addPath(top, `${rest}.${v}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const list = Array.isArray(relationNames) ? relationNames : [relationNames];
|
|
80
|
+
for (const item of list) {
|
|
81
|
+
processItem(item);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return tree;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Load relations for a set of records using batch queries (supports nested relations)
|
|
12
89
|
* @param {Object[]} records - Records to load relations for
|
|
13
|
-
* @param {string
|
|
90
|
+
* @param {Array<string|Object>} relationNames - Names of relations to load (e.g. ['author.profile', 'comments.author'])
|
|
14
91
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
15
92
|
* @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
|
|
16
93
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
17
94
|
* @returns {Promise<Object[]>} Records with relations attached
|
|
18
95
|
*/
|
|
19
96
|
async function loadRelations(records, relationNames, model, knex, scopeContext) {
|
|
20
|
-
if (!records.length || !relationNames.length) {
|
|
97
|
+
if (!records || !records.length || !relationNames || !relationNames.length) {
|
|
21
98
|
return records;
|
|
22
99
|
}
|
|
23
100
|
|
|
24
101
|
const context = scopeContext || createScopeContext();
|
|
102
|
+
const tree = parseRelationTree(relationNames);
|
|
25
103
|
|
|
26
|
-
// Process each relation
|
|
27
|
-
for (const relationName of
|
|
28
|
-
const relation = model.relations[relationName];
|
|
104
|
+
// Process each relation grouped by top-level
|
|
105
|
+
for (const [relationName, nestedPaths] of tree.entries()) {
|
|
106
|
+
const relation = model.relations?.[relationName];
|
|
29
107
|
if (!relation) {
|
|
30
108
|
console.warn(`Relation "${relationName}" not found on model "${model.name}"`);
|
|
31
109
|
continue;
|
|
@@ -43,6 +121,38 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
|
|
|
43
121
|
case 'hasOne':
|
|
44
122
|
await loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, context);
|
|
45
123
|
break;
|
|
124
|
+
default:
|
|
125
|
+
console.warn(`Unsupported relation type "${relation.type}" for relation "${relationName}" on model "${model.name}"`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Process nested relations if any
|
|
130
|
+
if (nestedPaths.length > 0) {
|
|
131
|
+
const childRecords = [];
|
|
132
|
+
const seenRefs = new Set();
|
|
133
|
+
const len = records.length;
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < len; i++) {
|
|
136
|
+
const child = records[i][relationName];
|
|
137
|
+
if (!child) continue;
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(child)) {
|
|
140
|
+
for (let j = 0; j < child.length; j++) {
|
|
141
|
+
const item = child[j];
|
|
142
|
+
if (item && typeof item === 'object' && !seenRefs.has(item)) {
|
|
143
|
+
seenRefs.add(item);
|
|
144
|
+
childRecords.push(item);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if (typeof child === 'object' && !seenRefs.has(child)) {
|
|
148
|
+
seenRefs.add(child);
|
|
149
|
+
childRecords.push(child);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (childRecords.length > 0) {
|
|
154
|
+
await loadRelations(childRecords, nestedPaths, relatedModel, knex, context);
|
|
155
|
+
}
|
|
46
156
|
}
|
|
47
157
|
}
|
|
48
158
|
|
|
@@ -116,6 +226,14 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
|
|
|
116
226
|
relatedModel
|
|
117
227
|
);
|
|
118
228
|
|
|
229
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
230
|
+
if (jsonCols.size > 0) {
|
|
231
|
+
const rLen = relatedRecords.length;
|
|
232
|
+
for (let i = 0; i < rLen; i++) {
|
|
233
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
119
237
|
const relatedMap = new Map();
|
|
120
238
|
const rLen = relatedRecords.length;
|
|
121
239
|
const pk = relatedModel.primaryKey;
|
|
@@ -171,6 +289,14 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
|
|
|
171
289
|
relatedModel
|
|
172
290
|
);
|
|
173
291
|
|
|
292
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
293
|
+
if (jsonCols.size > 0) {
|
|
294
|
+
const rLen = relatedRecords.length;
|
|
295
|
+
for (let i = 0; i < rLen; i++) {
|
|
296
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
174
300
|
const relatedGroups = new Map();
|
|
175
301
|
const rLen = relatedRecords.length;
|
|
176
302
|
for (let i = 0; i < rLen; i++) {
|
|
@@ -229,6 +355,14 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
|
|
|
229
355
|
relatedModel
|
|
230
356
|
);
|
|
231
357
|
|
|
358
|
+
const jsonCols = getJsonColumns(relatedModel);
|
|
359
|
+
if (jsonCols.size > 0) {
|
|
360
|
+
const rLen = relatedRecords.length;
|
|
361
|
+
for (let i = 0; i < rLen; i++) {
|
|
362
|
+
deserializeJsonFields(relatedRecords[i], jsonCols);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
232
366
|
const relatedMap = new Map();
|
|
233
367
|
const rLen = relatedRecords.length;
|
|
234
368
|
for (let i = 0; i < rLen; i++) {
|
|
@@ -247,9 +381,9 @@ async function loadHasOne(records, relationName, localKey, foreignKey, relatedMo
|
|
|
247
381
|
}
|
|
248
382
|
|
|
249
383
|
/**
|
|
250
|
-
* Load a single relation for a single record
|
|
384
|
+
* Load a single relation (or nested relation path) for a single record
|
|
251
385
|
* @param {Object} record - Record to load relation for
|
|
252
|
-
* @param {string} relationName - Name of relation to load
|
|
386
|
+
* @param {string} relationName - Name or dot-separated path of relation to load (e.g. 'author.profile')
|
|
253
387
|
* @param {import('./types').ModelDefinition} model - Model definition
|
|
254
388
|
* @param {import('knex').Knex} knex - Knex instance
|
|
255
389
|
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
@@ -267,5 +401,5 @@ module.exports = {
|
|
|
267
401
|
loadHasMany,
|
|
268
402
|
loadHasOne,
|
|
269
403
|
queryInChunks,
|
|
404
|
+
parseRelationTree,
|
|
270
405
|
};
|
|
271
|
-
|
package/core/orm/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const { createSchemaHelpers, extractColumnsFromSchema, getColumnMeta } = require('./schema-helpers');
|
|
10
|
-
const { defineModel, getModel, getAllModels, hasModel, clearRegistry } = require('./model');
|
|
10
|
+
const { defineModel, getModel, getAllModels, hasModel, clearRegistry, resolveModelName } = require('./model');
|
|
11
11
|
const { createRepository } = require('./repository');
|
|
12
12
|
const { createMigrationManager } = require('./migrations');
|
|
13
13
|
const { createSeeder } = require('./seeder');
|
|
@@ -172,13 +172,11 @@ function createDatabase(config) {
|
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
174
|
* Get a model by name
|
|
175
|
-
* @param {string|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
175
|
+
* @param {string|Object|import('./types').ModelDefinition} nameOrModel - Model name or model definition
|
|
176
176
|
* @returns {import('./types').ModelDefinition}
|
|
177
177
|
*/
|
|
178
178
|
function getModelInstance(nameOrModel) {
|
|
179
|
-
const name =
|
|
180
|
-
? nameOrModel.name
|
|
181
|
-
: nameOrModel;
|
|
179
|
+
const name = resolveModelName(nameOrModel);
|
|
182
180
|
|
|
183
181
|
// First check local registry
|
|
184
182
|
let model = models.get(name);
|
|
@@ -199,11 +197,12 @@ function createDatabase(config) {
|
|
|
199
197
|
|
|
200
198
|
/**
|
|
201
199
|
* Check if a model exists
|
|
202
|
-
* @param {string} name - Model name
|
|
200
|
+
* @param {string|Object} name - Model name
|
|
203
201
|
* @returns {boolean}
|
|
204
202
|
*/
|
|
205
203
|
function hasModelInstance(name) {
|
|
206
|
-
|
|
204
|
+
const modelName = resolveModelName(name);
|
|
205
|
+
return models.has(modelName) || hasModel(modelName);
|
|
207
206
|
}
|
|
208
207
|
|
|
209
208
|
/**
|
|
@@ -211,6 +210,12 @@ function createDatabase(config) {
|
|
|
211
210
|
* @returns {Array<import('./types').ModelDefinition>}
|
|
212
211
|
*/
|
|
213
212
|
function getAllModelInstances() {
|
|
213
|
+
const globalModels = getAllModels();
|
|
214
|
+
for (const [name, model] of globalModels) {
|
|
215
|
+
if (!models.has(name)) {
|
|
216
|
+
models.set(name, model);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
214
219
|
return Array.from(models.values());
|
|
215
220
|
}
|
|
216
221
|
|
|
@@ -367,9 +372,17 @@ module.exports = {
|
|
|
367
372
|
Hooks,
|
|
368
373
|
HookCancellationError,
|
|
369
374
|
createEventContext,
|
|
375
|
+
// Eager loading & nested relations
|
|
376
|
+
loadRelations: require('./eager-loader').loadRelations,
|
|
377
|
+
loadRelation: require('./eager-loader').loadRelation,
|
|
378
|
+
parseRelationTree: require('./eager-loader').parseRelationTree,
|
|
370
379
|
// Query Complexity & DoS Protection
|
|
371
380
|
QueryComplexityError: require('./complexity').QueryComplexityError,
|
|
372
381
|
validateQueryComplexity: require('./complexity').validateQueryComplexity,
|
|
373
382
|
resolveQueryLimits: require('./complexity').resolveQueryLimits,
|
|
374
383
|
DEFAULT_QUERY_LIMITS: require('./complexity').DEFAULT_QUERY_LIMITS,
|
|
384
|
+
// Universal Full-Text Search
|
|
385
|
+
buildSearchClause: require('./search').buildSearchClause,
|
|
386
|
+
tokenizeSearchTerm: require('./search').tokenizeSearchTerm,
|
|
387
|
+
detectDialect: require('./search').detectDialect,
|
|
375
388
|
};
|