webspresso 0.0.106 → 0.0.107

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 (49) hide show
  1. package/bin/webspresso.js +17 -7
  2. package/core/app.js +38 -6
  3. package/core/auth/index.js +4 -1
  4. package/core/auth/jwt.js +85 -13
  5. package/core/auth/magic-link.js +23 -5
  6. package/core/auth/manager.js +7 -1
  7. package/core/auth/redis-session.js +62 -25
  8. package/core/auth/session-manager.js +28 -2
  9. package/core/auth/sessions.js +182 -52
  10. package/core/console/eval.js +4 -1
  11. package/core/content/service.js +1 -0
  12. package/core/errors/domain.js +20 -2
  13. package/core/errors/http.js +1 -0
  14. package/core/http/hono-adapter.js +1 -1
  15. package/core/http/pipeline.js +36 -2
  16. package/core/index.js +12 -1
  17. package/core/mail/mailer.js +1 -1
  18. package/core/orm/eager-loader.js +81 -1
  19. package/core/orm/index.js +27 -13
  20. package/core/orm/json-fields.js +44 -13
  21. package/core/orm/model.js +12 -0
  22. package/core/orm/query-builder.js +33 -15
  23. package/core/orm/repository.js +59 -12
  24. package/core/orm/scopes.js +54 -4
  25. package/core/services/index.js +9 -0
  26. package/core/shutdown/shutdown-manager.js +57 -38
  27. package/core/studio/inspector.js +9 -1
  28. package/core/studio/server.js +24 -8
  29. package/core/utils/string.js +77 -16
  30. package/core/validation/index.js +18 -0
  31. package/index.d.ts +172 -8
  32. package/index.js +4 -0
  33. package/package.json +18 -8
  34. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  35. package/plugins/admin-panel/index.js +24 -11
  36. package/plugins/admin-panel/modules/user-management.js +3 -2
  37. package/plugins/csrf/index.js +2 -1
  38. package/plugins/dashboard/index.js +27 -7
  39. package/src/app-context.js +9 -1
  40. package/src/file-router.js +12 -3
  41. package/src/plugin-manager.js +273 -48
  42. package/src/server.js +26 -33
  43. package/src/services/discovery.js +9 -1
  44. package/src/services/executor.js +271 -58
  45. package/src/services/index.js +52 -3
  46. package/src/services/loader.js +120 -0
  47. package/src/services/memoize.js +223 -49
  48. package/src/services/registry.js +492 -94
  49. package/src/services/validator.js +164 -13
@@ -102,29 +102,65 @@ function createKnexSessionTracker(db, options = {}) {
102
102
  };
103
103
  }
104
104
 
105
- const [id] = await knex(tableName).insert({
106
- session_id: sessionId,
107
- user_id: userId,
108
- ip_address: ip,
109
- user_agent: ua ? ua.slice(0, 1000) : null,
110
- browser,
111
- os,
112
- device,
113
- last_active_at: now,
114
- created_at: now,
115
- });
105
+ try {
106
+ const [id] = await knex(tableName).insert({
107
+ session_id: sessionId,
108
+ user_id: userId,
109
+ ip_address: ip,
110
+ user_agent: ua ? ua.slice(0, 1000) : null,
111
+ browser,
112
+ os,
113
+ device,
114
+ last_active_at: now,
115
+ created_at: now,
116
+ });
116
117
 
117
- return {
118
- id,
119
- session_id: sessionId,
120
- user_id: userId,
121
- ip_address: ip,
122
- browser,
123
- os,
124
- device,
125
- last_active_at: now,
126
- created_at: now,
127
- };
118
+ return {
119
+ id,
120
+ session_id: sessionId,
121
+ user_id: userId,
122
+ ip_address: ip,
123
+ browser,
124
+ os,
125
+ device,
126
+ last_active_at: now,
127
+ created_at: now,
128
+ };
129
+ } catch (insertErr) {
130
+ // Handle concurrent insert race condition gracefully across database engines
131
+ const isUniqueConstraint =
132
+ insertErr.code === 'SQLITE_CONSTRAINT' ||
133
+ insertErr.code === '23505' || // PostgreSQL
134
+ insertErr.code === 'ER_DUP_ENTRY' || // MySQL
135
+ insertErr.errno === 19 || // SQLite
136
+ insertErr.errno === 1062 || // MySQL
137
+ (typeof insertErr.message === 'string' && /unique|duplicate|constraint/i.test(insertErr.message));
138
+
139
+ if (!isUniqueConstraint) {
140
+ throw insertErr;
141
+ }
142
+
143
+ await knex(tableName).where({ session_id: sessionId }).update({
144
+ user_id: userId,
145
+ ip_address: ip,
146
+ user_agent: ua ? ua.slice(0, 1000) : null,
147
+ browser,
148
+ os,
149
+ device,
150
+ last_active_at: now,
151
+ });
152
+
153
+ const record = await knex(tableName).where({ session_id: sessionId }).first();
154
+ return record || {
155
+ session_id: sessionId,
156
+ user_id: userId,
157
+ ip_address: ip,
158
+ browser,
159
+ os,
160
+ device,
161
+ last_active_at: now,
162
+ };
163
+ }
128
164
  },
129
165
 
130
166
  /**
@@ -415,24 +451,92 @@ function createKvSessionTracker(kv, options = {}) {
415
451
  return record;
416
452
  },
417
453
 
418
- listUserSessions: async (userId) => {
419
- // If list is supported by KV
420
- if (typeof kv.list === 'function') {
421
- const listRes = await kv.list({ prefix });
422
- const keys = listRes.keys || [];
423
- const results = [];
424
- for (const k of keys) {
425
- const item = await kvGet(k.name);
426
- if (item && String(item.user_id) === String(userId)) {
427
- results.push(item);
428
- }
454
+ /**
455
+ * Touch session to update last_active_at and refresh expiration
456
+ * @param {string} sessionId
457
+ * @returns {Promise<boolean>}
458
+ */
459
+ touch: async (sessionId) => {
460
+ if (!sessionId) return false;
461
+ const record = await kvGet(`${prefix}${sessionId}`);
462
+ if (!record) return false;
463
+ record.last_active_at = new Date().toISOString();
464
+ await kvPut(`${prefix}${sessionId}`, record, defaultTtl);
465
+ return true;
466
+ },
467
+
468
+ /**
469
+ * Get session record by session_id
470
+ * @param {string} sessionId
471
+ * @returns {Promise<Object|null>}
472
+ */
473
+ get: async (sessionId) => {
474
+ if (!sessionId) return null;
475
+ return (await kvGet(`${prefix}${sessionId}`)) || null;
476
+ },
477
+
478
+ /**
479
+ * List all active sessions for a user, marking current session
480
+ * @param {string|number} userId
481
+ * @param {string} [currentSessionId]
482
+ * @returns {Promise<Array<Object>>}
483
+ */
484
+ list: async (userId, currentSessionId = null) => {
485
+ if (!userId) return [];
486
+ if (typeof kv.list !== 'function') return [];
487
+ const listRes = await kv.list({ prefix });
488
+ const keys = listRes.keys || [];
489
+ const items = await Promise.all(
490
+ keys.map((k) => kvGet(k.name).catch(() => null))
491
+ );
492
+ const results = [];
493
+ for (const item of items) {
494
+ if (item && String(item.user_id) === String(userId)) {
495
+ results.push({
496
+ ...item,
497
+ is_current: Boolean(currentSessionId && item.session_id === currentSessionId),
498
+ });
429
499
  }
430
- return results;
431
500
  }
432
- return [];
501
+ return results.sort((a, b) => new Date(b.last_active_at).getTime() - new Date(a.last_active_at).getTime());
502
+ },
503
+
504
+ /**
505
+ * Legacy alias for list()
506
+ * @param {string|number} userId
507
+ * @returns {Promise<Array<Object>>}
508
+ */
509
+ listUserSessions: async (userId) => {
510
+ if (!userId || typeof kv.list !== 'function') return [];
511
+ const listRes = await kv.list({ prefix });
512
+ const keys = listRes.keys || [];
513
+ const items = await Promise.all(
514
+ keys.map((k) => kvGet(k.name).catch(() => null))
515
+ );
516
+ return items.filter((item) => item && String(item.user_id) === String(userId));
517
+ },
518
+
519
+ /**
520
+ * Check if a session exists and belongs to the given user
521
+ * @param {string} sessionId
522
+ * @param {string|number} [userId]
523
+ * @returns {Promise<boolean>}
524
+ */
525
+ isSessionValid: async (sessionId, userId = null) => {
526
+ if (!sessionId) return false;
527
+ const record = await kvGet(`${prefix}${sessionId}`);
528
+ if (!record) return false;
529
+ if (userId && String(record.user_id) !== String(userId)) return false;
530
+ return true;
433
531
  },
434
532
 
435
- validate: async (sessionId, userId) => {
533
+ /**
534
+ * Legacy alias for isSessionValid()
535
+ * @param {string} sessionId
536
+ * @param {string|number} [userId]
537
+ * @returns {Promise<boolean>}
538
+ */
539
+ validate: async (sessionId, userId = null) => {
436
540
  if (!sessionId) return false;
437
541
  const record = await kvGet(`${prefix}${sessionId}`);
438
542
  if (!record) return false;
@@ -440,38 +544,64 @@ function createKvSessionTracker(kv, options = {}) {
440
544
  return true;
441
545
  },
442
546
 
547
+ /**
548
+ * Revoke a single session
549
+ * @param {string} sessionId
550
+ * @returns {Promise<boolean>}
551
+ */
443
552
  revoke: async (sessionId) => {
444
553
  if (!sessionId) return false;
445
554
  await kvDelete(`${prefix}${sessionId}`);
446
555
  return true;
447
556
  },
448
557
 
558
+ /**
559
+ * Revoke all other sessions for user, keeping the current session active
560
+ * @param {string|number} userId
561
+ * @param {string} currentSessionId
562
+ * @returns {Promise<number>} Number of revoked sessions
563
+ */
449
564
  revokeOthers: async (userId, currentSessionId) => {
450
- if (typeof kv.list !== 'function') return 0;
565
+ if (!userId || typeof kv.list !== 'function') return 0;
451
566
  const listRes = await kv.list({ prefix });
452
- let count = 0;
453
- for (const k of listRes.keys || []) {
454
- const item = await kvGet(k.name);
455
- if (item && String(item.user_id) === String(userId) && item.session_id !== currentSessionId) {
456
- await kvDelete(k.name);
457
- count++;
567
+ const keys = listRes.keys || [];
568
+ const items = await Promise.all(
569
+ keys.map((k) => kvGet(k.name).then((item) => ({ key: k.name, item })).catch(() => null))
570
+ );
571
+ const toDelete = [];
572
+ for (const entry of items) {
573
+ if (entry?.item && String(entry.item.user_id) === String(userId) && entry.item.session_id !== currentSessionId) {
574
+ toDelete.push(entry.key);
458
575
  }
459
576
  }
460
- return count;
577
+ if (toDelete.length > 0) {
578
+ await Promise.all(toDelete.map((k) => kvDelete(k)));
579
+ }
580
+ return toDelete.length;
461
581
  },
462
582
 
583
+ /**
584
+ * Revoke all sessions for a user (force logout from all devices)
585
+ * @param {string|number} userId
586
+ * @returns {Promise<number>} Number of revoked sessions
587
+ */
463
588
  revokeAll: async (userId) => {
464
- if (typeof kv.list !== 'function') return 0;
589
+ if (!userId || typeof kv.list !== 'function') return 0;
465
590
  const listRes = await kv.list({ prefix });
466
- let count = 0;
467
- for (const k of listRes.keys || []) {
468
- const item = await kvGet(k.name);
469
- if (item && String(item.user_id) === String(userId)) {
470
- await kvDelete(k.name);
471
- count++;
591
+ const keys = listRes.keys || [];
592
+ const items = await Promise.all(
593
+ keys.map((k) => kvGet(k.name).then((item) => ({ key: k.name, item })).catch(() => null))
594
+ );
595
+ const toDelete = [];
596
+ for (const entry of items) {
597
+ if (entry?.item && String(entry.item.user_id) === String(userId)) {
598
+ toDelete.push(entry.key);
472
599
  }
473
600
  }
474
- return count;
601
+ if (toDelete.length > 0) {
602
+ await Promise.all(toDelete.map((k) => kvDelete(k)));
603
+ }
604
+ return toDelete.length;
475
605
  },
476
606
 
477
607
  purgeExpired: async () => {
@@ -15,7 +15,10 @@ function wrapBlock(code) {
15
15
  const lastLine = lines[lines.length - 1].trim();
16
16
  const isStatementKeyword = /^(return|const|let|var|throw|if|while|for|function|class|try|switch|import|export)\b/.test(lastLine);
17
17
  if (!isStatementKeyword && lastLine.length > 0) {
18
- const expr = lastLine.replace(/;+$/, '');
18
+ let expr = lastLine;
19
+ while (expr.endsWith(';')) {
20
+ expr = expr.slice(0, -1);
21
+ }
19
22
  lines[lines.length - 1] = `return (${expr});`;
20
23
  }
21
24
  return lines.join('\n');
@@ -400,6 +400,7 @@ function createContentService(db, options = {}) {
400
400
  createEntry,
401
401
  updateEntry,
402
402
  deleteEntry,
403
+ invalidateCache: () => cache.invalidateAll(),
403
404
  };
404
405
  }
405
406
 
@@ -17,20 +17,38 @@ class ValidationError extends HttpError {
17
17
  * @param {string} [message='Validation failed']
18
18
  * @param {Object} [options]
19
19
  * @param {Record<string, string[]|string>} [options.fields={}] - Field-specific validation error messages
20
+ * @param {Record<string, string[]>} [options.fieldErrors={}] - Detailed array of errors per field
20
21
  * @param {number} [options.status=422]
22
+ * @param {number} [options.statusCode]
21
23
  * @param {string} [options.code='VALIDATION_ERROR']
24
+ * @param {string} [options.service] - Service identifier if originating from services layer
22
25
  * @param {unknown} [options.details]
26
+ * @param {unknown} [options.issues]
23
27
  * @param {unknown} [options.cause]
24
28
  */
25
29
  constructor(message = 'Validation failed', options = {}) {
26
- super(options.status || 422, message, {
30
+ const status = typeof options.status === 'number'
31
+ ? options.status
32
+ : (typeof options.statusCode === 'number' ? options.statusCode : 422);
33
+
34
+ super(status, message, {
27
35
  code: options.code || 'VALIDATION_ERROR',
28
36
  expose: true,
29
37
  ...options,
30
38
  });
31
39
 
40
+ this.statusCode = this.status;
32
41
  this.fields = options.fields || {};
33
- this.issues = options.issues || options.details || [];
42
+ if (options.fieldErrors) {
43
+ this.fieldErrors = options.fieldErrors;
44
+ }
45
+ if (options.issues !== undefined || options.details !== undefined) {
46
+ this.issues = options.issues !== undefined ? options.issues : (options.details || []);
47
+ this.details = options.details !== undefined ? options.details : this.issues;
48
+ }
49
+ if (options.service) {
50
+ this.service = options.service;
51
+ }
34
52
  }
35
53
  }
36
54
 
@@ -23,6 +23,7 @@ class HttpError extends WebspressoError {
23
23
  super(message, options);
24
24
 
25
25
  this.status = typeof status === 'number' ? status : 500;
26
+ this.statusCode = this.status;
26
27
  this.headers = options.headers || {};
27
28
  this.expose = options.expose !== undefined ? Boolean(options.expose) : this.status < 500;
28
29
  }
@@ -341,7 +341,7 @@ class HonoHttpAdapter extends HttpAdapter {
341
341
  route(method, path, ...handlers) {
342
342
  const m = (method || '').toLowerCase();
343
343
  // Normalize Express bracket parameters: e.g. /api/notes/[id] -> /api/notes/:id
344
- const honoPath = path.replace(/\[([^\]]+)\]/g, ':$1');
344
+ const honoPath = typeof path === 'string' ? path.replace(/\[([^\[\]]+)\]/g, ':$1') : path;
345
345
 
346
346
  for (const handler of handlers) {
347
347
  if (typeof handler !== 'function') continue;
@@ -107,10 +107,32 @@ async function executeApiPipeline(params) {
107
107
  // 1. Injections
108
108
  if (db && !req.db) req.db = db;
109
109
  if (!req.service && serviceRegistry) {
110
+ const getCallContext = (explicitCtx) => {
111
+ if (explicitCtx) return explicitCtx;
112
+ const ctx = req.context ? { ...req.context } : { req, res, db: req.db || db };
113
+ if (!ctx.req) ctx.req = req;
114
+ if (!ctx.res) ctx.res = res;
115
+ if (!ctx.db) ctx.db = req.db || db;
116
+ if (req.user !== undefined) ctx.user = req.user;
117
+ if (req.auth !== undefined) ctx.auth = req.auth;
118
+ if (req.session !== undefined) ctx.session = req.session;
119
+ return ctx;
120
+ };
110
121
  req.service = (name, input, opts) => {
111
- const baseContext = req.context || { req, res, db, user: req.user, session: req.session };
112
- return serviceRegistry.call(name, input, baseContext, opts);
122
+ return serviceRegistry.call(name, input, getCallContext(opts?.ctx), opts);
113
123
  };
124
+ req.service.call = (name, input, opts) => req.service(name, input, opts);
125
+ req.service.execute = (name, input, opts) => req.service(name, input, opts);
126
+ req.service.invalidate = (name, input, ctx) =>
127
+ serviceRegistry.invalidate(name, input, getCallContext(ctx));
128
+ req.service.clearCache = (name) =>
129
+ serviceRegistry.clearCache(name);
130
+ req.service.invalidateByTag = (tag, serviceName) =>
131
+ serviceRegistry.invalidateByTag(tag, serviceName);
132
+ req.service.invalidateByTags = (tags, serviceName) =>
133
+ serviceRegistry.invalidateByTags(tags, serviceName);
134
+ req.service.invalidateTag = req.service.invalidateByTag;
135
+ req.service.invalidateTags = req.service.invalidateByTags;
114
136
  }
115
137
 
116
138
  // 2. Schema validation
@@ -261,6 +283,18 @@ async function executePagePipeline(params) {
261
283
  const baseContext = req.context || { req, res, db, user: req.user, session: req.session };
262
284
  req.service = (name, input, opts) =>
263
285
  serviceRegistry.call(name, input, baseContext, opts);
286
+ req.service.call = (name, input, opts) => req.service(name, input, opts);
287
+ req.service.execute = (name, input, opts) => req.service(name, input, opts);
288
+ req.service.invalidate = (name, input, ctx) =>
289
+ serviceRegistry.invalidate(name, input, ctx || baseContext);
290
+ req.service.clearCache = (name) =>
291
+ serviceRegistry.clearCache(name);
292
+ req.service.invalidateByTag = (tag, serviceName) =>
293
+ serviceRegistry.invalidateByTag(tag, serviceName);
294
+ req.service.invalidateByTags = (tags, serviceName) =>
295
+ serviceRegistry.invalidateByTags(tags, serviceName);
296
+ req.service.invalidateTag = req.service.invalidateByTag;
297
+ req.service.invalidateTags = req.service.invalidateByTags;
264
298
  }
265
299
 
266
300
  // 2. Middlewares
package/core/index.js CHANGED
@@ -11,9 +11,11 @@
11
11
  const { CoreApp, createCoreApp } = require('./app');
12
12
  const {
13
13
  createServiceRegistry,
14
+ createServiceRegistryAsync,
14
15
  defineService,
15
16
  ServiceRegistry,
16
17
  executeService,
18
+ composeMiddleware,
17
19
  discoverServices,
18
20
  filePathToServiceName,
19
21
  toCamelCase,
@@ -23,7 +25,10 @@ const {
23
25
  stableCacheKey,
24
26
  sanitizeInput,
25
27
  maskSensitiveData,
26
- } = require('../src/services');
28
+ loadModuleSync,
29
+ loadModuleAsync,
30
+ extractModuleExport,
31
+ } = require('./services');
27
32
 
28
33
  const { ShutdownManager } = require('./shutdown');
29
34
  const errors = require('./errors');
@@ -54,11 +59,14 @@ module.exports = {
54
59
  createCoreApp,
55
60
 
56
61
  // Services Layer
62
+ services: require('./services'),
57
63
  createServiceRegistry,
64
+ createServiceRegistryAsync,
58
65
  defineService,
59
66
  service: defineService,
60
67
  ServiceRegistry,
61
68
  executeService,
69
+ composeMiddleware,
62
70
  discoverServices,
63
71
  filePathToServiceName,
64
72
  toCamelCase,
@@ -68,6 +76,9 @@ module.exports = {
68
76
  stableCacheKey,
69
77
  sanitizeInput,
70
78
  maskSensitiveData,
79
+ loadModuleSync,
80
+ loadModuleAsync,
81
+ extractModuleExport,
71
82
 
72
83
  // Route & Endpoint Declarations
73
84
  defineApi: require('../src/api/define-api').defineApi,
@@ -67,7 +67,7 @@ class Mailer {
67
67
 
68
68
  // Fallback simple text from HTML if text not provided
69
69
  if (!msg.text && typeof msg.html === 'string') {
70
- msg.text = msg.html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
70
+ msg.text = msg.html.replace(/<[^<>]*>/g, ' ').replace(/\s+/g, ' ').trim();
71
71
  }
72
72
 
73
73
  try {
@@ -8,6 +8,60 @@ const { resolveRelationModel, getRelationKeys } = require('./model');
8
8
  const { applyScopes, createScopeContext } = require('./scopes');
9
9
  const { getJsonColumns, deserializeJsonFields } = require('./json-fields');
10
10
 
11
+ const RELATION_TREE_CACHE_LIMIT = 512;
12
+ const relationTreeCache = new Map();
13
+
14
+ /**
15
+ * Generate a deterministic cache key for relation names specifications
16
+ * @param {string|Array|Object} relationNames
17
+ * @returns {string|null}
18
+ */
19
+ function getRelationTreeCacheKey(relationNames) {
20
+ if (typeof relationNames === 'string') {
21
+ return 's:' + relationNames;
22
+ }
23
+ if (Array.isArray(relationNames)) {
24
+ for (let i = 0; i < relationNames.length; i++) {
25
+ if (typeof relationNames[i] !== 'string') {
26
+ try {
27
+ return 'o:' + JSON.stringify(relationNames);
28
+ } catch (_) {
29
+ return null;
30
+ }
31
+ }
32
+ }
33
+ return 'a:' + relationNames.join('|');
34
+ }
35
+ if (relationNames && typeof relationNames === 'object') {
36
+ try {
37
+ return 'o:' + JSON.stringify(relationNames);
38
+ } catch (_) {
39
+ return null;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Clone a relation tree Map to prevent external mutation from poisoning cache
47
+ * @param {Map<string, string[]>} tree
48
+ * @returns {Map<string, string[]>}
49
+ */
50
+ function cloneRelationTree(tree) {
51
+ const copy = new Map();
52
+ for (const [k, v] of tree.entries()) {
53
+ copy.set(k, v.slice());
54
+ }
55
+ return copy;
56
+ }
57
+
58
+ /**
59
+ * Clear the internal relation tree cache
60
+ */
61
+ function clearRelationTreeCache() {
62
+ relationTreeCache.clear();
63
+ }
64
+
11
65
  /**
12
66
  * Parse relation specifications into a grouped structure: Map<topLevelRelation, string[]>
13
67
  * Supports dot notation ('author.profile'), arrays (['author', 'comments.author']),
@@ -17,6 +71,21 @@ const { getJsonColumns, deserializeJsonFields } = require('./json-fields');
17
71
  * @returns {Map<string, string[]>}
18
72
  */
19
73
  function parseRelationTree(relationNames) {
74
+ if (relationNames instanceof Map) {
75
+ return cloneRelationTree(relationNames);
76
+ }
77
+
78
+ const cacheKey = getRelationTreeCacheKey(relationNames);
79
+ if (cacheKey) {
80
+ const cached = relationTreeCache.get(cacheKey);
81
+ if (cached) {
82
+ // Refresh LRU order
83
+ relationTreeCache.delete(cacheKey);
84
+ relationTreeCache.set(cacheKey, cached);
85
+ return cloneRelationTree(cached);
86
+ }
87
+ }
88
+
20
89
  const tree = new Map();
21
90
 
22
91
  function addPath(top, nested) {
@@ -81,6 +150,15 @@ function parseRelationTree(relationNames) {
81
150
  processItem(item);
82
151
  }
83
152
 
153
+ if (cacheKey) {
154
+ if (relationTreeCache.size >= RELATION_TREE_CACHE_LIMIT) {
155
+ const firstKey = relationTreeCache.keys().next().value;
156
+ relationTreeCache.delete(firstKey);
157
+ }
158
+ relationTreeCache.set(cacheKey, tree);
159
+ return cloneRelationTree(tree);
160
+ }
161
+
84
162
  return tree;
85
163
  }
86
164
 
@@ -99,7 +177,7 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
99
177
  }
100
178
 
101
179
  const context = scopeContext || createScopeContext();
102
- const tree = parseRelationTree(relationNames);
180
+ const tree = relationNames instanceof Map ? relationNames : parseRelationTree(relationNames);
103
181
 
104
182
  // Process each relation grouped by top-level
105
183
  for (const [relationName, nestedPaths] of tree.entries()) {
@@ -402,4 +480,6 @@ module.exports = {
402
480
  loadHasOne,
403
481
  queryInChunks,
404
482
  parseRelationTree,
483
+ clearRelationTreeCache,
484
+ getRelationTreeCacheKey,
405
485
  };
package/core/orm/index.js CHANGED
@@ -9,6 +9,7 @@ const fs = require('fs');
9
9
  const { createSchemaHelpers, extractColumnsFromSchema, getColumnMeta } = require('./schema-helpers');
10
10
  const { defineModel, getModel, getAllModels, hasModel, clearRegistry, resolveModelName, isCascadeEnabled } = require('./model');
11
11
  const { createRepository } = require('./repository');
12
+ const { createQueryBuilder, QueryBuilder } = require('./query-builder');
12
13
  const { createMigrationManager } = require('./migrations');
13
14
  const { createSeeder } = require('./seeder');
14
15
  const { createScopeContext } = require('./scopes');
@@ -278,9 +279,8 @@ function createDatabase(config) {
278
279
  */
279
280
  function query(modelName, scopeContext) {
280
281
  const model = getModelInstance(modelName);
281
- const ctx = scopeContext || createScopeContext();
282
- const repo = createRepository(model, knexInstance, ctx, ormCacheLayer);
283
- return repo.query();
282
+ const ctx = scopeContext ? { ...scopeContext } : createScopeContext();
283
+ return createQueryBuilder(model, knexInstance, ctx, ormCacheLayer);
284
284
  }
285
285
 
286
286
  /**
@@ -325,6 +325,11 @@ function createDatabase(config) {
325
325
  const ctx = sContext || currentScope;
326
326
  return createRepository(model, trx, ctx, ormCacheLayer);
327
327
  },
328
+ query(modelName, sContext) {
329
+ const model = getModelInstance(modelName);
330
+ const ctx = sContext ? { ...sContext } : currentScope;
331
+ return createQueryBuilder(model, trx, ctx, ormCacheLayer);
332
+ },
328
333
  forTenant(tenantId) {
329
334
  currentScope = { ...currentScope, tenantId };
330
335
  return this;
@@ -472,6 +477,9 @@ const zdb = z ? createSchemaHelpers(z) : null;
472
477
  module.exports = {
473
478
  // Main factory
474
479
  createDatabase,
480
+ // Query builder
481
+ createQueryBuilder,
482
+ QueryBuilder,
475
483
  // ORM cache (provider + layer utilities)
476
484
  createMemoryCacheProvider: require('./cache/memory-provider').createMemoryCacheProvider,
477
485
  OrmCacheLayer: require('./cache/layer').OrmCacheLayer,
@@ -511,6 +519,7 @@ module.exports = {
511
519
  loadRelations: require('./eager-loader').loadRelations,
512
520
  loadRelation: require('./eager-loader').loadRelation,
513
521
  parseRelationTree: require('./eager-loader').parseRelationTree,
522
+ clearRelationTreeCache: require('./eager-loader').clearRelationTreeCache,
514
523
  // Query Complexity & DoS Protection
515
524
  QueryComplexityError: require('./complexity').QueryComplexityError,
516
525
  validateQueryComplexity: require('./complexity').validateQueryComplexity,
@@ -543,11 +552,16 @@ module.exports = {
543
552
  getUserId: require('./scopes').getUserId,
544
553
  getTenantId: require('./scopes').getTenantId,
545
554
  createScopeContext: require('./scopes').createScopeContext,
546
- // Cloudflare D1 Native Edge Dialect
547
- createD1Knex: require('./d1').createD1Knex,
548
- Client_D1: require('./d1').Client_D1,
549
- executeD1Batch: require('./d1').executeD1Batch,
550
- normalizeD1Bindings: require('./d1').normalizeD1Bindings,
555
+ getSoftDeleteColumn: require('./scopes').getSoftDeleteColumn,
556
+ getCreatedAtColumn: require('./scopes').getCreatedAtColumn,
557
+ getUpdatedAtColumn: require('./scopes').getUpdatedAtColumn,
558
+ applyInsertTimestamps: require('./scopes').applyInsertTimestamps,
559
+ applyUpdateTimestamps: require('./scopes').applyUpdateTimestamps,
560
+ // Cloudflare D1 Native Edge Dialect (lazy getters to avoid eager dialect loading)
561
+ get createD1Knex() { return require('./d1').createD1Knex; },
562
+ get Client_D1() { return require('./d1').Client_D1; },
563
+ get executeD1Batch() { return require('./d1').executeD1Batch; },
564
+ get normalizeD1Bindings() { return require('./d1').normalizeD1Bindings; },
551
565
  // Serverless Connection Pool Management
552
566
  isServerlessEnvironment,
553
567
  normalizeServerlessPoolConfig,
@@ -555,9 +569,9 @@ module.exports = {
555
569
  isServerlessEnvironment,
556
570
  normalizeServerlessPoolConfig,
557
571
  },
558
- // Cloud Database Migrations
559
- waitForDatabaseConnection: require('./migrate-cloud').waitForDatabaseConnection,
560
- freeStaleMigrationLock: require('./migrate-cloud').freeStaleMigrationLock,
561
- getMigrationStatus: require('./migrate-cloud').getMigrationStatus,
562
- runCloudMigration: require('./migrate-cloud').runCloudMigration,
572
+ // Cloud Database Migrations (lazy getters)
573
+ get waitForDatabaseConnection() { return require('./migrate-cloud').waitForDatabaseConnection; },
574
+ get freeStaleMigrationLock() { return require('./migrate-cloud').freeStaleMigrationLock; },
575
+ get getMigrationStatus() { return require('./migrate-cloud').getMigrationStatus; },
576
+ get runCloudMigration() { return require('./migrate-cloud').runCloudMigration; },
563
577
  };