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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. 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 summary = `${method.toUpperCase()} ${filePath}`;
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: ['api'],
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 - route metadata from mountPages
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 = path.join(absPages, route.file);
126
- if (!fs.existsSync(fullPath)) {
127
- continue;
128
- }
229
+ const fullPath = resolveRouteFilePath(route, pagesDir);
129
230
 
130
- let mod;
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
- try {
139
- compiled = compileSchema(fullPath, mod);
140
- } catch {
141
- compiled = null;
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 openApiPath = expressPathToOpenApi(route.pattern);
145
- const method = route.method.toLowerCase();
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
- keys.sort();
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
- const first = store.keys().next().value;
60
- if (first !== undefined) removeKey(first);
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
  };
@@ -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
- * Load relations for a set of records using batch queries
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[]} relationNames - Names of relations to load
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 relationNames) {
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 = typeof nameOrModel === 'object' && nameOrModel !== null && nameOrModel.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
- return models.has(name);
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
  };