webspresso 0.0.88 → 0.0.90
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/README.md +92 -45
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +12 -2
- package/core/orm/eager-loader.js +64 -70
- package/core/orm/events.js +19 -0
- package/core/orm/model.js +8 -2
- package/core/orm/repository.js +54 -22
- package/core/orm/schema-helpers.js +31 -2
- package/core/orm/scopes.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/08-record-list.js +10 -10
- package/plugins/admin-panel/index.js +41 -1
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/src/file-router.js +31 -14
- package/src/helpers.js +4 -3
- package/src/plugin-manager.js +10 -3
- package/src/server.js +184 -18
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/README.md
CHANGED
|
@@ -528,24 +528,39 @@ const { app } = createApp({
|
|
|
528
528
|
pagesDir: './pages',
|
|
529
529
|
viewsDir: './views',
|
|
530
530
|
errorPages: {
|
|
531
|
-
//
|
|
532
|
-
notFound: (req, res) =>
|
|
533
|
-
res.render('errors/404.njk', { url: req.url });
|
|
534
|
-
},
|
|
531
|
+
// 404 Not Found: Template path (relative to viewsDir) or custom handler fn
|
|
532
|
+
notFound: '404.njk', // or 'errors/404.njk', or (req, res, ctx) => res.render('404.njk', ctx)
|
|
535
533
|
|
|
536
|
-
//
|
|
537
|
-
serverError: 'errors/500.njk',
|
|
534
|
+
// 500 Internal Server Error: Template path or custom handler fn
|
|
535
|
+
serverError: 'errors/500.njk', // or (err, req, res, ctx) => ...
|
|
538
536
|
|
|
539
|
-
// Timeout
|
|
540
|
-
timeout: 'errors/503.njk'
|
|
537
|
+
// 503 Request Timeout: Template path or custom handler fn
|
|
538
|
+
timeout: 'errors/503.njk' // or (req, res, ctx) => ...
|
|
541
539
|
}
|
|
542
540
|
});
|
|
543
541
|
```
|
|
544
542
|
|
|
545
|
-
Error
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
-
|
|
543
|
+
#### Error Template Context & Behavior
|
|
544
|
+
|
|
545
|
+
All error templates rendered with Nunjucks automatically receive a rich request context:
|
|
546
|
+
- **`404.njk` (`notFound`)**: `{ fsy, locale, isDev, url, method }` — sets HTTP status `404`. If omitted, Webspresso provides a responsive built-in HTML 404 page for browsers, or `{ error: 'Not Found', status: 404 }` JSON for API requests.
|
|
547
|
+
- **`500.njk` (`serverError`)**: `{ fsy, locale, isDev, url, method, error, status }` — sets HTTP status `500` (or `err.status`).
|
|
548
|
+
- **`503.njk` (`timeout`)**: `{ fsy, locale, isDev, url, method }` — sets HTTP status `503`.
|
|
549
|
+
|
|
550
|
+
```njk
|
|
551
|
+
{# Example views/404.njk #}
|
|
552
|
+
{% extends "layout.njk" %}
|
|
553
|
+
|
|
554
|
+
{% block content %}
|
|
555
|
+
<div class="max-w-xl mx-auto text-center py-20">
|
|
556
|
+
<h1 class="text-6xl font-bold text-slate-800">404</h1>
|
|
557
|
+
<p class="text-xl text-slate-600 mt-4">Page Not Found: <code>{{ url }}</code></p>
|
|
558
|
+
<a href="{{ fsy.url('/') }}" class="mt-6 inline-block bg-blue-600 text-white px-6 py-2 rounded-lg">
|
|
559
|
+
Back to Home
|
|
560
|
+
</a>
|
|
561
|
+
</div>
|
|
562
|
+
{% endblock %}
|
|
563
|
+
```
|
|
549
564
|
|
|
550
565
|
**How errors reach this handler**
|
|
551
566
|
|
|
@@ -568,9 +583,11 @@ const { app } = createApp({
|
|
|
568
583
|
});
|
|
569
584
|
```
|
|
570
585
|
|
|
571
|
-
**Asset Management:**
|
|
586
|
+
**Asset Management & Cache-Busting:**
|
|
587
|
+
|
|
588
|
+
Webspresso includes a built-in `AssetManager` that automates static asset URL resolution, query-string cache-busting, Vite/Webpack build manifest lookups, and CDN prefixing in Nunjucks SSR templates.
|
|
572
589
|
|
|
573
|
-
Configure asset handling
|
|
590
|
+
Configure asset handling in `createApp`:
|
|
574
591
|
|
|
575
592
|
```javascript
|
|
576
593
|
const { createApp } = require('webspresso');
|
|
@@ -579,54 +596,84 @@ const path = require('path');
|
|
|
579
596
|
const { app } = createApp({
|
|
580
597
|
pagesDir: './pages',
|
|
581
598
|
viewsDir: './views',
|
|
582
|
-
publicDir: './public',
|
|
599
|
+
publicDir: './public', // default: 'public'
|
|
583
600
|
assets: {
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
601
|
+
// 1. Static query-string versioning (Cache Busting)
|
|
602
|
+
// Appends ?v=1.2.3 (or &v=... if existing query params) to asset URLs
|
|
603
|
+
version: process.env.APP_VERSION || '1.0.0', // or Git commit hash, build timestamp
|
|
604
|
+
|
|
605
|
+
// 2. Build Tool Manifest (Vite, Webpack, etc.)
|
|
606
|
+
// Resolves hashed bundle filenames automatically (e.g. style.css -> assets/style-abc123.css)
|
|
588
607
|
manifestPath: path.join(__dirname, 'public/.vite/manifest.json'),
|
|
589
|
-
|
|
590
|
-
// URL
|
|
591
|
-
|
|
608
|
+
|
|
609
|
+
// 3. Asset URL Prefix / CDN
|
|
610
|
+
// Prepended to all resolved asset paths
|
|
611
|
+
prefix: process.env.CDN_URL || '' // e.g. 'https://cdn.example.com' or '/static'
|
|
592
612
|
}
|
|
593
613
|
});
|
|
594
614
|
```
|
|
595
615
|
|
|
596
|
-
|
|
616
|
+
#### How Asset Resolution Works
|
|
617
|
+
|
|
618
|
+
1. **Manifest Match First**: If `manifestPath` is configured and an entry matches the asset key, `AssetManager` resolves to the hashed filename (from Vite or Webpack manifest) and prepends `prefix`.
|
|
619
|
+
2. **Fallback to Path & Versioning**: If no manifest is provided or the asset is not listed in the manifest, `AssetManager` uses the original path, prepends `prefix`, and appends `?v=<version>` (or `&v=<version>`) if `version` is set.
|
|
620
|
+
3. **Leading Slash Normalization**: Paths with or without leading slashes (e.g., `'css/style.css'` or `'/css/style.css'`) are handled seamlessly.
|
|
621
|
+
|
|
622
|
+
```json
|
|
623
|
+
// Example Vite manifest format (.vite/manifest.json)
|
|
624
|
+
{
|
|
625
|
+
"css/style.css": { "file": "assets/style-abc123.css", "src": "css/style.css" },
|
|
626
|
+
"js/app.js": { "file": "assets/app-xyz789.js", "src": "js/app.js" }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Example Webpack manifest format (manifest.json)
|
|
630
|
+
{
|
|
631
|
+
"/css/style.css": "/dist/style.abc123.css",
|
|
632
|
+
"/js/app.js": "/dist/app.xyz789.js"
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
#### Template Helpers (`fsy`)
|
|
637
|
+
|
|
638
|
+
Use `fsy` helpers inside your Nunjucks layouts and pages:
|
|
597
639
|
|
|
598
640
|
```njk
|
|
599
|
-
{#
|
|
641
|
+
{# 1. URL string resolution #}
|
|
600
642
|
<link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
|
|
643
|
+
{# Outputs: <link rel="stylesheet" href="/css/style.css?v=1.0.0"> (or /assets/style-abc123.css) #}
|
|
601
644
|
|
|
602
|
-
{#
|
|
645
|
+
{# 2. CSS link tag helper #}
|
|
603
646
|
{{ fsy.css('/css/style.css') | safe }}
|
|
604
|
-
{
|
|
605
|
-
{{ fsy.
|
|
647
|
+
{# With custom attributes (media, id, integrity, etc.): #}
|
|
648
|
+
{{ fsy.css('/css/print.css', { media: 'print', id: 'print-styles' }) | safe }}
|
|
649
|
+
|
|
650
|
+
{# 3. JS script tag helper #}
|
|
651
|
+
{{ fsy.js('/js/app.js') | safe }}
|
|
652
|
+
{# Supports boolean flags (async, defer) and module type: #}
|
|
653
|
+
{{ fsy.js('/js/main.js', { defer: true, type: 'module' }) | safe }}
|
|
654
|
+
{# Outputs: <script src="/js/main.js?v=1.0.0" defer type="module"></script> #}
|
|
655
|
+
|
|
656
|
+
{# 4. Image tag helper #}
|
|
657
|
+
{{ fsy.img('/images/logo.png', 'Company Logo') | safe }}
|
|
658
|
+
{# With attributes (loading, class, width, height): #}
|
|
659
|
+
{{ fsy.img('/images/hero.jpg', 'Hero Banner', { loading: 'lazy', class: 'w-full rounded', width: 1200, height: 600 }) | safe }}
|
|
606
660
|
```
|
|
607
661
|
|
|
608
|
-
|
|
609
|
-
- `asset(path)` - Returns versioned/manifest-resolved URL
|
|
610
|
-
- `css(href, attrs)` - Generates `<link>` tag
|
|
611
|
-
- `js(src, attrs)` - Generates `<script>` tag
|
|
612
|
-
- `img(src, alt, attrs)` - Generates `<img>` tag
|
|
662
|
+
#### Programmatic Asset Manager API
|
|
613
663
|
|
|
614
|
-
|
|
664
|
+
You can also import and use `AssetManager` directly:
|
|
615
665
|
|
|
616
|
-
|
|
666
|
+
```javascript
|
|
667
|
+
const { AssetManager, configureAssets, getAssetManager } = require('webspresso');
|
|
617
668
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
669
|
+
// Configure global manager
|
|
670
|
+
configureAssets({
|
|
671
|
+
version: '2.0.0',
|
|
672
|
+
prefix: 'https://cdn.example.com'
|
|
673
|
+
});
|
|
624
674
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
"/css/style.css": "/dist/style.abc123.css",
|
|
628
|
-
"/js/app.js": "/dist/app.xyz789.js"
|
|
629
|
-
}
|
|
675
|
+
const manager = getAssetManager();
|
|
676
|
+
console.log(manager.asset('/css/style.css')); // -> 'https://cdn.example.com/css/style.css?v=2.0.0'
|
|
630
677
|
```
|
|
631
678
|
|
|
632
679
|
**Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is `null` when `auth` was not passed.
|
package/core/auth/manager.js
CHANGED
|
@@ -38,7 +38,7 @@ const DEFAULT_CONFIG = {
|
|
|
38
38
|
saveUninitialized: false,
|
|
39
39
|
cookie: {
|
|
40
40
|
httpOnly: true,
|
|
41
|
-
secure: process.env.NODE_ENV === 'production',
|
|
41
|
+
secure: process.env.NODE_ENV === 'production' ? true : 'auto',
|
|
42
42
|
sameSite: 'lax',
|
|
43
43
|
maxAge: 24 * 60 * 60 * 1000, // 1 day
|
|
44
44
|
},
|
|
@@ -272,9 +272,11 @@ class AuthManager {
|
|
|
272
272
|
throw new AuthenticationError('Invalid user object', 'INVALID_USER');
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// Set session
|
|
276
|
-
req.session
|
|
277
|
-
|
|
275
|
+
// Set session if active
|
|
276
|
+
if (req.session) {
|
|
277
|
+
req.session.userId = user.id;
|
|
278
|
+
req.session.loggedInAt = Date.now();
|
|
279
|
+
}
|
|
278
280
|
|
|
279
281
|
// Set req.user
|
|
280
282
|
req.user = user;
|
|
@@ -284,19 +286,21 @@ class AuthManager {
|
|
|
284
286
|
await self.createRememberToken(user.id, res);
|
|
285
287
|
}
|
|
286
288
|
|
|
287
|
-
// Regenerate session ID for security
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
289
|
+
// Regenerate session ID for security if supported
|
|
290
|
+
if (req.session && typeof req.session.regenerate === 'function') {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
req.session.regenerate((err) => {
|
|
293
|
+
if (err) {
|
|
294
|
+
reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
|
|
295
|
+
} else {
|
|
296
|
+
// Restore userId after regenerate
|
|
297
|
+
req.session.userId = user.id;
|
|
298
|
+
req.session.loggedInAt = Date.now();
|
|
299
|
+
resolve();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
298
302
|
});
|
|
299
|
-
}
|
|
303
|
+
}
|
|
300
304
|
},
|
|
301
305
|
|
|
302
306
|
/**
|
|
@@ -320,21 +324,25 @@ class AuthManager {
|
|
|
320
324
|
}
|
|
321
325
|
|
|
322
326
|
// Clear cookie
|
|
323
|
-
res
|
|
327
|
+
if (typeof res?.clearCookie === 'function') {
|
|
328
|
+
res.clearCookie(self.config.rememberMe.cookieName);
|
|
329
|
+
}
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
// Clear user
|
|
327
333
|
req.user = null;
|
|
328
334
|
|
|
329
|
-
// Destroy session
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
// Destroy session if supported
|
|
336
|
+
if (req.session && typeof req.session.destroy === 'function') {
|
|
337
|
+
return new Promise((resolve) => {
|
|
338
|
+
req.session.destroy((err) => {
|
|
339
|
+
if (err) {
|
|
340
|
+
console.error('Session destroy error:', err);
|
|
341
|
+
}
|
|
342
|
+
resolve();
|
|
343
|
+
});
|
|
336
344
|
});
|
|
337
|
-
}
|
|
345
|
+
}
|
|
338
346
|
},
|
|
339
347
|
|
|
340
348
|
/**
|
|
@@ -715,7 +723,9 @@ class AuthManager {
|
|
|
715
723
|
const payload = {
|
|
716
724
|
id: user.id,
|
|
717
725
|
type: 'access',
|
|
718
|
-
[this.identifierField]: user[this.identifierField],
|
|
726
|
+
...(this.identifierField && user[this.identifierField] ? { [this.identifierField]: user[this.identifierField] } : {}),
|
|
727
|
+
...(user.email && { email: user.email }),
|
|
728
|
+
...(user.role && { role: user.role }),
|
|
719
729
|
...options.payload,
|
|
720
730
|
};
|
|
721
731
|
return this.createJwt(payload, options);
|
package/core/auth/middleware.js
CHANGED
|
@@ -15,11 +15,21 @@ const cookieParser = require('cookie-parser');
|
|
|
15
15
|
function createAuthMiddleware(authManager) {
|
|
16
16
|
const config = authManager.config;
|
|
17
17
|
|
|
18
|
+
const sessionConfig = authManager.getSessionConfig();
|
|
19
|
+
|
|
18
20
|
/**
|
|
19
21
|
* Session middleware
|
|
20
|
-
* Sets up express-session
|
|
22
|
+
* Sets up express-session with secure cookie enforcement
|
|
21
23
|
*/
|
|
22
|
-
const sessionMiddleware = session(
|
|
24
|
+
const sessionMiddleware = session({
|
|
25
|
+
...sessionConfig,
|
|
26
|
+
cookie: {
|
|
27
|
+
httpOnly: true,
|
|
28
|
+
sameSite: 'lax',
|
|
29
|
+
secure: 'auto',
|
|
30
|
+
...(sessionConfig.cookie || {}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* Cookie parser middleware (needed for signed cookies / remember me)
|
package/core/orm/eager-loader.js
CHANGED
|
@@ -63,39 +63,37 @@ async function loadRelations(records, relationNames, model, knex, scopeContext)
|
|
|
63
63
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
64
64
|
*/
|
|
65
65
|
async function loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
record[relationName] = null;
|
|
66
|
+
const fkSet = new Set();
|
|
67
|
+
const len = records.length;
|
|
68
|
+
for (let i = 0; i < len; i++) {
|
|
69
|
+
const v = records[i][foreignKey];
|
|
70
|
+
if (v !== null && v !== undefined) fkSet.add(v);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (fkSet.size === 0) {
|
|
74
|
+
for (let i = 0; i < len; i++) {
|
|
75
|
+
records[i][relationName] = null;
|
|
77
76
|
}
|
|
78
77
|
return;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
const foreignKeyValues = Array.from(fkSet);
|
|
82
81
|
let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
|
|
83
|
-
|
|
84
|
-
// Apply scopes to related model
|
|
85
82
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
86
|
-
|
|
87
83
|
const relatedRecords = await qb;
|
|
88
84
|
|
|
89
|
-
// Index related records by primary key
|
|
90
85
|
const relatedMap = new Map();
|
|
91
|
-
|
|
92
|
-
|
|
86
|
+
const rLen = relatedRecords.length;
|
|
87
|
+
const pk = relatedModel.primaryKey;
|
|
88
|
+
for (let i = 0; i < rLen; i++) {
|
|
89
|
+
const item = relatedRecords[i];
|
|
90
|
+
relatedMap.set(item[pk], item);
|
|
93
91
|
}
|
|
94
92
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const fkValue =
|
|
98
|
-
|
|
93
|
+
for (let i = 0; i < len; i++) {
|
|
94
|
+
const rec = records[i];
|
|
95
|
+
const fkValue = rec[foreignKey];
|
|
96
|
+
rec[relationName] = fkValue !== null && fkValue !== undefined
|
|
99
97
|
? relatedMap.get(fkValue) || null
|
|
100
98
|
: null;
|
|
101
99
|
}
|
|
@@ -115,43 +113,42 @@ async function loadBelongsTo(records, relationName, localKey, foreignKey, relate
|
|
|
115
113
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
116
114
|
*/
|
|
117
115
|
async function loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
record[relationName] = [];
|
|
116
|
+
const pkSet = new Set();
|
|
117
|
+
const len = records.length;
|
|
118
|
+
for (let i = 0; i < len; i++) {
|
|
119
|
+
const v = records[i][localKey];
|
|
120
|
+
if (v !== null && v !== undefined) pkSet.add(v);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (pkSet.size === 0) {
|
|
124
|
+
for (let i = 0; i < len; i++) {
|
|
125
|
+
records[i][relationName] = [];
|
|
129
126
|
}
|
|
130
127
|
return;
|
|
131
128
|
}
|
|
132
129
|
|
|
133
|
-
|
|
130
|
+
const primaryKeyValues = Array.from(pkSet);
|
|
134
131
|
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
135
|
-
|
|
136
|
-
// Apply scopes to related model
|
|
137
132
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
138
|
-
|
|
139
133
|
const relatedRecords = await qb;
|
|
140
134
|
|
|
141
|
-
// Group related records by foreign key
|
|
142
135
|
const relatedGroups = new Map();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
136
|
+
const rLen = relatedRecords.length;
|
|
137
|
+
for (let i = 0; i < rLen; i++) {
|
|
138
|
+
const item = relatedRecords[i];
|
|
139
|
+
const fkValue = item[foreignKey];
|
|
140
|
+
let group = relatedGroups.get(fkValue);
|
|
141
|
+
if (group === undefined) {
|
|
142
|
+
group = [];
|
|
143
|
+
relatedGroups.set(fkValue, group);
|
|
147
144
|
}
|
|
148
|
-
|
|
145
|
+
group.push(item);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const pkValue =
|
|
154
|
-
|
|
148
|
+
for (let i = 0; i < len; i++) {
|
|
149
|
+
const rec = records[i];
|
|
150
|
+
const pkValue = rec[localKey];
|
|
151
|
+
rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedGroups.get(pkValue) : undefined) || [];
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
|
|
@@ -169,42 +166,39 @@ async function loadHasMany(records, relationName, localKey, foreignKey, relatedM
|
|
|
169
166
|
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
170
167
|
*/
|
|
171
168
|
async function loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
record[relationName] = null;
|
|
169
|
+
const pkSet = new Set();
|
|
170
|
+
const len = records.length;
|
|
171
|
+
for (let i = 0; i < len; i++) {
|
|
172
|
+
const v = records[i][localKey];
|
|
173
|
+
if (v !== null && v !== undefined) pkSet.add(v);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (pkSet.size === 0) {
|
|
177
|
+
for (let i = 0; i < len; i++) {
|
|
178
|
+
records[i][relationName] = null;
|
|
183
179
|
}
|
|
184
180
|
return;
|
|
185
181
|
}
|
|
186
182
|
|
|
187
|
-
|
|
183
|
+
const primaryKeyValues = Array.from(pkSet);
|
|
188
184
|
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
189
|
-
|
|
190
|
-
// Apply scopes to related model
|
|
191
185
|
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
192
|
-
|
|
193
186
|
const relatedRecords = await qb;
|
|
194
187
|
|
|
195
|
-
// Index related records by foreign key (first occurrence wins for hasOne)
|
|
196
188
|
const relatedMap = new Map();
|
|
197
|
-
|
|
198
|
-
|
|
189
|
+
const rLen = relatedRecords.length;
|
|
190
|
+
for (let i = 0; i < rLen; i++) {
|
|
191
|
+
const item = relatedRecords[i];
|
|
192
|
+
const fkValue = item[foreignKey];
|
|
199
193
|
if (!relatedMap.has(fkValue)) {
|
|
200
|
-
relatedMap.set(fkValue,
|
|
194
|
+
relatedMap.set(fkValue, item);
|
|
201
195
|
}
|
|
202
196
|
}
|
|
203
197
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const pkValue =
|
|
207
|
-
|
|
198
|
+
for (let i = 0; i < len; i++) {
|
|
199
|
+
const rec = records[i];
|
|
200
|
+
const pkValue = rec[localKey];
|
|
201
|
+
rec[relationName] = (pkValue !== null && pkValue !== undefined ? relatedMap.get(pkValue) : undefined) || null;
|
|
208
202
|
}
|
|
209
203
|
}
|
|
210
204
|
|
package/core/orm/events.js
CHANGED
|
@@ -154,6 +154,23 @@ class ModelEventsClass {
|
|
|
154
154
|
return listeners.length > 0 ? listeners : EMPTY_ARRAY;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Fast boolean check whether any listener matches
|
|
159
|
+
* @param {string} model - Model name
|
|
160
|
+
* @param {string} hook - Hook name
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
hasListeners(model, hook) {
|
|
164
|
+
if (this.listeners.size === 0) return false;
|
|
165
|
+
const l = this.listeners;
|
|
166
|
+
return (
|
|
167
|
+
l.has(`${model}.${hook}`) ||
|
|
168
|
+
l.has(`*.${hook}`) ||
|
|
169
|
+
l.has(`${model}.*`) ||
|
|
170
|
+
l.has('*.*')
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
/**
|
|
158
175
|
* Emit an event synchronously (for after hooks)
|
|
159
176
|
* @param {string} model - Model name
|
|
@@ -163,6 +180,7 @@ class ModelEventsClass {
|
|
|
163
180
|
*/
|
|
164
181
|
emit(model, hook, data, context) {
|
|
165
182
|
const listeners = this.getMatchingListeners(model, hook);
|
|
183
|
+
if (listeners.length === 0) return;
|
|
166
184
|
const ctx = context || createEventContext(model, hook);
|
|
167
185
|
|
|
168
186
|
for (const listener of listeners) {
|
|
@@ -187,6 +205,7 @@ class ModelEventsClass {
|
|
|
187
205
|
async emitAsync(model, hook, data, context) {
|
|
188
206
|
const listeners = this.getMatchingListeners(model, hook);
|
|
189
207
|
const ctx = context || createEventContext(model, hook);
|
|
208
|
+
if (listeners.length === 0) return ctx;
|
|
190
209
|
|
|
191
210
|
for (const listener of listeners) {
|
|
192
211
|
if (ctx.isCancelled) break;
|
package/core/orm/model.js
CHANGED
|
@@ -54,6 +54,7 @@ function defineModel(options) {
|
|
|
54
54
|
const columns = extractColumnsFromSchema(schema);
|
|
55
55
|
|
|
56
56
|
// Validate relations
|
|
57
|
+
const normalizedRelations = {};
|
|
57
58
|
for (const [relationName, relation] of Object.entries(relations)) {
|
|
58
59
|
if (!['belongsTo', 'hasMany', 'hasOne'].includes(relation.type)) {
|
|
59
60
|
throw new Error(
|
|
@@ -70,6 +71,8 @@ function defineModel(options) {
|
|
|
70
71
|
`Relation "${relationName}" in model "${name}" must have a foreignKey string`
|
|
71
72
|
);
|
|
72
73
|
}
|
|
74
|
+
|
|
75
|
+
normalizedRelations[relationName] = relation;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
// Create model definition
|
|
@@ -78,7 +81,7 @@ function defineModel(options) {
|
|
|
78
81
|
table,
|
|
79
82
|
schema,
|
|
80
83
|
primaryKey,
|
|
81
|
-
relations,
|
|
84
|
+
relations: normalizedRelations,
|
|
82
85
|
scopes: {
|
|
83
86
|
softDelete: scopes.softDelete || false,
|
|
84
87
|
timestamps: scopes.timestamps || false,
|
|
@@ -186,7 +189,10 @@ function unregisterModel(name) {
|
|
|
186
189
|
* @returns {import('./types').ModelDefinition}
|
|
187
190
|
*/
|
|
188
191
|
function resolveRelationModel(relation) {
|
|
189
|
-
|
|
192
|
+
let model = typeof relation.model === 'function' ? relation.model() : relation.model;
|
|
193
|
+
if (typeof model === 'string') {
|
|
194
|
+
model = getModel(model);
|
|
195
|
+
}
|
|
190
196
|
if (!model || !model.name) {
|
|
191
197
|
throw new Error('Invalid relation model reference');
|
|
192
198
|
}
|