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 CHANGED
@@ -528,24 +528,39 @@ const { app } = createApp({
528
528
  pagesDir: './pages',
529
529
  viewsDir: './views',
530
530
  errorPages: {
531
- // Option 1: Custom handler function
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
- // Option 2: Template path (rendered with Nunjucks)
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 error page (503)
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 templates receive these variables:
546
- - `404.njk`: `{ url, method }`
547
- - `500.njk`: `{ error, status, isDev }`
548
- - `503.njk`: `{ url, method, isDev }`
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 with versioning and manifest support:
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
- // Option 1: Simple versioning (cache busting)
585
- version: '1.2.3', // or process.env.APP_VERSION
586
-
587
- // Option 2: Manifest file (Vite, Webpack, etc.)
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 prefix for assets
591
- prefix: '/static'
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
- Use asset helpers in templates:
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
- {# Using fsy helpers (auto-resolved) #}
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
- {# Or generate full HTML tags #}
645
+ {# 2. CSS link tag helper #}
603
646
  {{ fsy.css('/css/style.css') | safe }}
604
- {{ fsy.js('/js/app.js', { defer: true, type: 'module' }) | safe }}
605
- {{ fsy.img('/images/logo.png', 'Site Logo', { class: 'logo', loading: 'lazy' }) | safe }}
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
- Asset helpers available in `fsy`:
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
- **Manifest Support:**
664
+ You can also import and use `AssetManager` directly:
615
665
 
616
- Works with Vite and Webpack manifest formats:
666
+ ```javascript
667
+ const { AssetManager, configureAssets, getAssetManager } = require('webspresso');
617
668
 
618
- ```json
619
- // Vite manifest format (.vite/manifest.json)
620
- {
621
- "css/style.css": { "file": "assets/style-abc123.css" },
622
- "js/app.js": { "file": "assets/app-xyz789.js" }
623
- }
669
+ // Configure global manager
670
+ configureAssets({
671
+ version: '2.0.0',
672
+ prefix: 'https://cdn.example.com'
673
+ });
624
674
 
625
- // Webpack manifest format
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.
@@ -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.userId = user.id;
277
- req.session.loggedInAt = Date.now();
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
- return new Promise((resolve, reject) => {
289
- req.session.regenerate((err) => {
290
- if (err) {
291
- reject(new AuthenticationError('Session regeneration failed', 'SESSION_ERROR'));
292
- } else {
293
- // Restore userId after regenerate
294
- req.session.userId = user.id;
295
- req.session.loggedInAt = Date.now();
296
- resolve();
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.clearCookie(self.config.rememberMe.cookieName);
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
- return new Promise((resolve) => {
331
- req.session.destroy((err) => {
332
- if (err) {
333
- console.error('Session destroy error:', err);
334
- }
335
- resolve();
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);
@@ -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(authManager.getSessionConfig());
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)
@@ -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
- // Collect unique foreign key values
67
- const foreignKeyValues = [...new Set(
68
- records
69
- .map(r => r[foreignKey])
70
- .filter(v => v !== null && v !== undefined)
71
- )];
72
-
73
- if (foreignKeyValues.length === 0) {
74
- // No foreign keys, set all relations to null
75
- for (const record of records) {
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
- // Build query for related records
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
- for (const related of relatedRecords) {
92
- relatedMap.set(related[relatedModel.primaryKey], related);
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
- // Attach related records to parent records
96
- for (const record of records) {
97
- const fkValue = record[foreignKey];
98
- record[relationName] = fkValue !== null && fkValue !== undefined
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
- // Collect unique primary key values from parent records
119
- const primaryKeyValues = [...new Set(
120
- records
121
- .map(r => r[localKey])
122
- .filter(v => v !== null && v !== undefined)
123
- )];
124
-
125
- if (primaryKeyValues.length === 0) {
126
- // No primary keys, set all relations to empty arrays
127
- for (const record of records) {
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
- // Build query for related records
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
- for (const related of relatedRecords) {
144
- const fkValue = related[foreignKey];
145
- if (!relatedGroups.has(fkValue)) {
146
- relatedGroups.set(fkValue, []);
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
- relatedGroups.get(fkValue).push(related);
145
+ group.push(item);
149
146
  }
150
147
 
151
- // Attach related records to parent records
152
- for (const record of records) {
153
- const pkValue = record[localKey];
154
- record[relationName] = relatedGroups.get(pkValue) || [];
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
- // Collect unique primary key values from parent records
173
- const primaryKeyValues = [...new Set(
174
- records
175
- .map(r => r[localKey])
176
- .filter(v => v !== null && v !== undefined)
177
- )];
178
-
179
- if (primaryKeyValues.length === 0) {
180
- // No primary keys, set all relations to null
181
- for (const record of records) {
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
- // Build query for related records
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
- for (const related of relatedRecords) {
198
- const fkValue = related[foreignKey];
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, related);
194
+ relatedMap.set(fkValue, item);
201
195
  }
202
196
  }
203
197
 
204
- // Attach related records to parent records
205
- for (const record of records) {
206
- const pkValue = record[localKey];
207
- record[relationName] = relatedMap.get(pkValue) || null;
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
 
@@ -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
- const model = relation.model();
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
  }