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.
- package/bin/webspresso.js +17 -7
- package/core/app.js +38 -6
- package/core/auth/index.js +4 -1
- package/core/auth/jwt.js +85 -13
- package/core/auth/magic-link.js +23 -5
- package/core/auth/manager.js +7 -1
- package/core/auth/redis-session.js +62 -25
- package/core/auth/session-manager.js +28 -2
- package/core/auth/sessions.js +182 -52
- package/core/console/eval.js +4 -1
- package/core/content/service.js +1 -0
- package/core/errors/domain.js +20 -2
- package/core/errors/http.js +1 -0
- package/core/http/hono-adapter.js +1 -1
- package/core/http/pipeline.js +36 -2
- package/core/index.js +12 -1
- package/core/mail/mailer.js +1 -1
- package/core/orm/eager-loader.js +81 -1
- package/core/orm/index.js +27 -13
- package/core/orm/json-fields.js +44 -13
- package/core/orm/model.js +12 -0
- package/core/orm/query-builder.js +33 -15
- package/core/orm/repository.js +59 -12
- package/core/orm/scopes.js +54 -4
- package/core/services/index.js +9 -0
- package/core/shutdown/shutdown-manager.js +57 -38
- package/core/studio/inspector.js +9 -1
- package/core/studio/server.js +24 -8
- package/core/utils/string.js +77 -16
- package/core/validation/index.js +18 -0
- package/index.d.ts +172 -8
- package/index.js +4 -0
- package/package.json +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
- package/plugins/admin-panel/index.js +24 -11
- package/plugins/admin-panel/modules/user-management.js +3 -2
- package/plugins/csrf/index.js +2 -1
- package/plugins/dashboard/index.js +27 -7
- package/src/app-context.js +9 -1
- package/src/file-router.js +12 -3
- package/src/plugin-manager.js +273 -48
- package/src/server.js +26 -33
- package/src/services/discovery.js +9 -1
- package/src/services/executor.js +271 -58
- package/src/services/index.js +52 -3
- package/src/services/loader.js +120 -0
- package/src/services/memoize.js +223 -49
- package/src/services/registry.js +492 -94
- package/src/services/validator.js +164 -13
package/core/auth/sessions.js
CHANGED
|
@@ -102,29 +102,65 @@ function createKnexSessionTracker(db, options = {}) {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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 () => {
|
package/core/console/eval.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/core/content/service.js
CHANGED
package/core/errors/domain.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/core/errors/http.js
CHANGED
|
@@ -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;
|
package/core/http/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/core/mail/mailer.js
CHANGED
|
@@ -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(/<[
|
|
70
|
+
msg.text = msg.html.replace(/<[^<>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
try {
|
package/core/orm/eager-loader.js
CHANGED
|
@@ -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
|
|
282
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
560
|
-
freeStaleMigrationLock
|
|
561
|
-
getMigrationStatus
|
|
562
|
-
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
|
};
|