@fuzionx/framework 0.1.61 → 0.1.62

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.
@@ -8,15 +8,22 @@
8
8
  * @see docs/framework/17-config.md
9
9
  */
10
10
  import { createRequire } from 'node:module';
11
+ import { pathToFileURL } from 'node:url';
12
+ import path from 'node:path';
11
13
 
12
14
  const _require = createRequire(import.meta.url);
15
+ /** CWD 기반 require — 프로젝트 node_modules에서 resolve */
16
+ const _cwdRequire = createRequire(pathToFileURL(path.join(process.cwd(), '_resolve.js')).href);
13
17
 
14
18
  /**
15
19
  * optional dependency 안전 로드
20
+ * 프레임워크 경로 → CWD 경로 순으로 시도
16
21
  * @private
17
22
  */
18
23
  function tryRequire(name) {
19
- try { return _require(name); } catch { return null; }
24
+ try { return _require(name); } catch {
25
+ try { return _cwdRequire(name); } catch { return null; }
26
+ }
20
27
  }
21
28
 
22
29
  export default class ConnectionManager {
@@ -144,8 +151,20 @@ export default class ConnectionManager {
144
151
  conn.connect = () => {
145
152
  if (conn._connected) return Promise.resolve();
146
153
  if (conn._connectPromise) return conn._connectPromise;
147
- const uri = config.uri || config.url
148
- || `mongodb://${config.host || '127.0.0.1'}:${config.port || 27017}/${config.database || ''}`;
154
+ // user/password가 있으면 URI에 인증 정보 포함
155
+ let uri = config.uri || config.url;
156
+ if (!uri) {
157
+ const host = config.host || '127.0.0.1';
158
+ const port = config.port || 27017;
159
+ const db = config.database || '';
160
+ if (config.user && config.password) {
161
+ // 인증 정보 포함 URI + authSource 지정
162
+ const authSource = config.authSource || config.database || 'admin';
163
+ uri = `mongodb://${encodeURIComponent(config.user)}:${encodeURIComponent(config.password)}@${host}:${port}/${db}?authSource=${authSource}`;
164
+ } else {
165
+ uri = `mongodb://${host}:${port}/${db}`;
166
+ }
167
+ }
149
168
  conn._connectPromise = mongoose.connect(uri, config.options || {}).then(() => {
150
169
  conn._connected = true;
151
170
  });
@@ -16,9 +16,23 @@ export default class Model {
16
16
  static softDelete = false; // deleted_at 소프트 삭제
17
17
  static hidden = []; // JSON 직렬화 시 제외 필드
18
18
  static columns = {}; // SQL 스키마 정의 (fx db:sync 용)
19
- static indexes = []; // 인덱스 정의 [{ columns: ['role'], unique? }]
19
+ static indexes = []; // 인덱스 정의 [{ columns: ['role_code'], unique?: boolean, name?: string }]
20
20
  static schema = {}; // MongoDB 스키마
21
21
 
22
+ /**
23
+ * 관계 정의 — 서브클래스에서 오버라이드
24
+ *
25
+ * @example
26
+ * static relations = {
27
+ * wallet: { type: 'hasOne', model: 'Wallet', foreignKey: 'user_id' },
28
+ * posts: { type: 'hasMany', model: 'Post', foreignKey: 'user_id' },
29
+ * role: { type: 'belongsTo', model: 'Role', foreignKey: 'role_code', ownerKey: 'code' },
30
+ * };
31
+ *
32
+ * @type {Object<string, { type: string, model: string, foreignKey: string, ownerKey?: string }>}
33
+ */
34
+ static relations = {};
35
+
22
36
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
23
37
  // 인스턴스 (레코드 래퍼)
24
38
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -31,6 +31,12 @@ export default class SqlModel extends Model {
31
31
  */
32
32
  static _connectionManager = null;
33
33
 
34
+ /**
35
+ * ModelRegistry 인스턴스 (Application에서 주입 — relation resolve용)
36
+ * @type {import('./ModelRegistry.js').default|null}
37
+ */
38
+ static _modelRegistry = null;
39
+
34
40
  /**
35
41
  * ConnectionManager를 모든 SqlModel 서브클래스에 주입
36
42
  * @param {import('./ConnectionManager.js').default} cm
@@ -39,6 +45,14 @@ export default class SqlModel extends Model {
39
45
  SqlModel._connectionManager = cm;
40
46
  }
41
47
 
48
+ /**
49
+ * ModelRegistry를 모든 SqlModel 서브클래스에 주입 (relation eager-loading용)
50
+ * @param {import('./ModelRegistry.js').default} registry
51
+ */
52
+ static setModelRegistry(registry) {
53
+ SqlModel._modelRegistry = registry;
54
+ }
55
+
42
56
  /**
43
57
  * DB 연결 가져오기
44
58
  * @returns {object} connection object
@@ -33,17 +33,22 @@ export default class SqlQueryBuilder extends QueryBuilder {
33
33
  */
34
34
  async get() {
35
35
  const conn = this._model.getConnection();
36
+ let results;
36
37
 
37
38
  if (conn.type === 'sqlite' && conn.db) {
38
- return this._executeSqlite(conn.db, 'select');
39
+ results = this._executeSqlite(conn.db, 'select');
40
+ } else if (conn.type === 'knex' && conn.db) {
41
+ results = await this._executeKnex(conn.db, 'select');
42
+ } else {
43
+ results = [];
39
44
  }
40
45
 
41
- if (conn.type === 'knex' && conn.db) {
42
- return this._executeKnex(conn.db, 'select');
46
+ // eager-loading with()로 지정된 관계 로드
47
+ if (this._withs.length > 0 && results.length > 0) {
48
+ await this._loadRelations(results);
43
49
  }
44
50
 
45
- // Stub: no connection
46
- return [];
51
+ return results;
47
52
  }
48
53
 
49
54
  /**
@@ -155,7 +160,7 @@ export default class SqlQueryBuilder extends QueryBuilder {
155
160
  const total = await this.count();
156
161
  this._limit = perPage;
157
162
  this._offset = (page - 1) * perPage;
158
- const data = await this.get();
163
+ const data = await this.get(); // get() 내부에서 relation 자동 로드
159
164
  return new Pagination(data, total, page, perPage);
160
165
  }
161
166
 
@@ -329,4 +334,84 @@ export default class SqlQueryBuilder extends QueryBuilder {
329
334
  }
330
335
  return query;
331
336
  }
337
+
338
+ // ━━━━━━━━━━ Relation Eager-Loading ━━━━━━━━━━
339
+
340
+ /**
341
+ * with()로 지정된 관계를 일괄 조회하여 결과에 첨부 (N+1 방지)
342
+ *
343
+ * 지원 관계 타입:
344
+ * - hasOne: 1:1 (현재 PK → 대상 FK)
345
+ * - hasMany: 1:N (현재 PK → 대상 FK)
346
+ * - belongsTo: N:1 (현재 FK → 대상 PK/ownerKey)
347
+ *
348
+ * @param {Array<Model>} results - 메인 조회 결과
349
+ * @private
350
+ */
351
+ async _loadRelations(results) {
352
+ const modelRelations = this._model.relations || {};
353
+
354
+ for (const relName of this._withs) {
355
+ const rel = modelRelations[relName];
356
+ if (!rel) continue; // 정의되지 않은 관계는 무시
357
+
358
+ // 관계 대상 모델 클래스 resolve (문자열 → ModelRegistry에서 찾기)
359
+ let RelatedModel = rel.model;
360
+ if (typeof RelatedModel === 'string') {
361
+ // SqlModel._modelRegistry를 통해 모델 클래스 resolve
362
+ const registry = this._model._modelRegistry || this._model.constructor._modelRegistry;
363
+ if (registry) {
364
+ RelatedModel = registry.get(rel.model);
365
+ }
366
+ if (!RelatedModel || typeof RelatedModel === 'string') {
367
+ continue; // resolve 실패 시 스킵
368
+ }
369
+ }
370
+
371
+ const foreignKey = rel.foreignKey;
372
+ const ownerKey = rel.ownerKey || this._model.primaryKey; // belongsTo의 대상 키
373
+ const localKey = rel.localKey || this._model.primaryKey; // hasOne/hasMany의 로컬 키
374
+
375
+ if (rel.type === 'hasOne' || rel.type === 'hasMany') {
376
+ // hasOne/hasMany: 현재 모델의 localKey 값 수집 → 대상 모델의 foreignKey로 조회
377
+ const localValues = [...new Set(results.map(r => r._attributes[localKey]).filter(v => v != null))];
378
+ if (localValues.length === 0) continue;
379
+
380
+ const related = await RelatedModel.query().whereIn(foreignKey, localValues).get();
381
+
382
+ // 결과 매핑 (FK → 로컬키 기준)
383
+ if (rel.type === 'hasOne') {
384
+ const map = new Map();
385
+ for (const r of related) map.set(r._attributes[foreignKey], r);
386
+ for (const row of results) {
387
+ row[relName] = map.get(row._attributes[localKey]) || null;
388
+ }
389
+ } else {
390
+ // hasMany: FK별로 그룹화
391
+ const map = new Map();
392
+ for (const r of related) {
393
+ const key = r._attributes[foreignKey];
394
+ if (!map.has(key)) map.set(key, []);
395
+ map.get(key).push(r);
396
+ }
397
+ for (const row of results) {
398
+ row[relName] = map.get(row._attributes[localKey]) || [];
399
+ }
400
+ }
401
+
402
+ } else if (rel.type === 'belongsTo') {
403
+ // belongsTo: 현재 모델의 foreignKey 값 수집 → 대상 모델의 ownerKey로 조회
404
+ const fkValues = [...new Set(results.map(r => r._attributes[foreignKey]).filter(v => v != null))];
405
+ if (fkValues.length === 0) continue;
406
+
407
+ const related = await RelatedModel.query().whereIn(ownerKey, fkValues).get();
408
+ const map = new Map();
409
+ for (const r of related) map.set(r._attributes[ownerKey], r);
410
+
411
+ for (const row of results) {
412
+ row[relName] = map.get(row._attributes[foreignKey]) || null;
413
+ }
414
+ }
415
+ }
416
+ }
332
417
  }
@@ -12,3 +12,4 @@ export { csrf } from './csrf.js';
12
12
  export { session } from './session.js';
13
13
  export { theme } from './theme.js';
14
14
  export { loadUser } from './loadUser.js';
15
+ export { roleGuard } from './roleGuard.js';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * roleGuard — 역할 기반 접근 제어 미들웨어
3
+ *
4
+ * `auth()` 미들웨어 이후에 사용.
5
+ * ctx.user.role_code가 허용된 역할 목록에 포함되는지 검사.
6
+ *
7
+ * @example
8
+ * // 관리자 백오피스: developer, admin만 허용
9
+ * r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin'])] }, (r) => { ... });
10
+ *
11
+ * // 리셀러 전용: reseller만 허용
12
+ * r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin', 'reseller'])] }, (r) => { ... });
13
+ *
14
+ * @see docs/framework/14-authentication.md
15
+ *
16
+ * @param {string[]} allowedRoles - 접근 허용 역할 코드 배열
17
+ * @param {object} [opts]
18
+ * @param {string} [opts.roleField='role_code'] - user 객체에서 역할을 읽을 필드명
19
+ * @param {string} [opts.message='auth.role_denied'] - 거부 시 에러 메시지 키
20
+ * @returns {Function} 미들웨어 함수
21
+ */
22
+ export function roleGuard(allowedRoles, opts = {}) {
23
+ const roleField = opts.roleField || 'role_code';
24
+ const message = opts.message || 'auth.role_denied';
25
+
26
+ return async (ctx, next) => {
27
+ /** 인증되지 않은 사용자 */
28
+ if (!ctx.user) {
29
+ ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
30
+ return;
31
+ }
32
+
33
+ /** 역할 코드 추출 */
34
+ const userRole = ctx.user[roleField];
35
+
36
+ /** 허용된 역할인지 확인 */
37
+ if (!userRole || !allowedRoles.includes(userRole)) {
38
+ ctx.status(403).json({
39
+ error: {
40
+ message: ctx.t ? ctx.t(message) : message,
41
+ status: 403,
42
+ },
43
+ });
44
+ return;
45
+ }
46
+
47
+ await next();
48
+ };
49
+ }
@@ -1,19 +1,72 @@
1
1
  /**
2
- * theme — 도메인 → 테마 매핑 미들웨어
2
+ * theme — 도메인 → 템플릿(테마) 매핑 미들웨어
3
+ *
4
+ * DB에 Domain 모델이 등록되어 있으면 요청 호스트로 조회하여
5
+ * ctx.theme을 해당 템플릿 코드로 설정.
6
+ * DB 없거나 매칭 도메인이 없으면 fuzionx.yaml의 기본 테마 사용.
7
+ *
8
+ * 성능: 캐시를 통해 매 요청마다 DB 조회를 방지.
3
9
  *
4
10
  * @see docs/framework/03-views-templates.md
5
11
  * @see docs/framework/12-middleware.md — "theme: 도메인 → 테마 매핑"
6
12
  *
7
13
  * @param {object} [opts]
8
14
  * @param {string} [opts.default='default'] - 기본 테마
9
- * @param {object} [opts.mapping] - { 'domain.com': 'theme1' }
10
15
  */
11
16
  export function theme(opts = {}) {
12
17
  const defaultTheme = opts.default || 'default';
13
18
 
14
19
  return async (ctx, next) => {
15
20
  const configDefault = ctx.app?.config?.get('app.themes.default') || defaultTheme;
16
- ctx.theme = configDefault;
21
+
22
+ // DB의 Domain 모델이 등록되어 있으면 호스트로 조회
23
+ const DomainModel = ctx.app?.db?.Domain;
24
+ if (DomainModel) {
25
+ const host = ctx.headers?.host || ''; // '127.0.0.1:49080' 또는 'example.com'
26
+
27
+ // 캐시 키: 도메인 호스트별
28
+ const cacheKey = `theme:${host}`;
29
+ let templateCode = null;
30
+
31
+ // CacheManager가 있으면 캐시 사용 (5분)
32
+ if (ctx.app._cacheManager) {
33
+ templateCode = await ctx.app._cacheManager.remember(cacheKey, 300, async () => {
34
+ const domainRecord = await DomainModel
35
+ .where('domain', host)
36
+ .where('is_active', true)
37
+ .first();
38
+
39
+ if (domainRecord) {
40
+ // 관계 로드하여 template.code 가져오기
41
+ const TemplateModel = ctx.app.db.Template;
42
+ if (TemplateModel) {
43
+ const template = await TemplateModel.find(domainRecord.template_id);
44
+ if (template?.is_active) return template.code;
45
+ }
46
+ }
47
+ return null; // null도 캐시 (DB 미스 반복 방지)
48
+ });
49
+ } else {
50
+ // 캐시 없으면 직접 조회
51
+ const domainRecord = await DomainModel
52
+ .where('domain', host)
53
+ .where('is_active', true)
54
+ .first();
55
+
56
+ if (domainRecord) {
57
+ const TemplateModel = ctx.app.db.Template;
58
+ if (TemplateModel) {
59
+ const template = await TemplateModel.find(domainRecord.template_id);
60
+ if (template?.is_active) templateCode = template.code;
61
+ }
62
+ }
63
+ }
64
+
65
+ ctx.theme = templateCode || configDefault;
66
+ } else {
67
+ // Domain 모델 미등록 시 YAML 기본값 사용
68
+ ctx.theme = configDefault;
69
+ }
17
70
 
18
71
  await next();
19
72
  };
@@ -72,7 +72,10 @@ export default class Service extends Base {
72
72
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
73
 
74
74
  /**
75
- * TTL 기반 인메모리 캐시로 감싸기
75
+ * TTL 기반 캐시로 감싸기
76
+ *
77
+ * CacheManager가 초기화되어 있으면 Redis/File 캐시 사용.
78
+ * 없으면 인메모리 Map 폴백 (워커 간 공유 안됨).
76
79
  *
77
80
  * @param {string} key - 캐시 키
78
81
  * @param {number} ttl - TTL (초)
@@ -85,6 +88,12 @@ export default class Service extends Base {
85
88
  * });
86
89
  */
87
90
  async withCache(key, ttl, fn) {
91
+ // CacheManager가 있으면 Redis/File 캐시 사용
92
+ if (this.cache) {
93
+ return this.cache.remember(key, ttl, fn);
94
+ }
95
+
96
+ // 폴백: 인메모리 Map (워커 간 공유 불가)
88
97
  const cached = _cache.get(key);
89
98
  if (cached && cached.expires > Date.now()) {
90
99
  return cached.value;
@@ -102,23 +111,32 @@ export default class Service extends Base {
102
111
  * 캐시 키 무효화
103
112
  * @param {string} key
104
113
  */
105
- invalidateCache(key) {
114
+ async invalidateCache(key) {
115
+ if (this.cache) {
116
+ await this.cache.delete(key);
117
+ }
106
118
  _cache.delete(key);
107
119
  }
108
120
 
109
121
  /**
110
122
  * 전체 캐시 초기화
111
123
  */
112
- clearCache() {
124
+ async clearCache() {
125
+ if (this.cache) {
126
+ await this.cache.flush();
127
+ }
113
128
  _cache.clear();
114
129
  }
115
130
 
116
131
  /**
117
132
  * 캐시 존재 여부
118
133
  * @param {string} key
119
- * @returns {boolean}
134
+ * @returns {Promise<boolean>}
120
135
  */
121
- hasCache(key) {
136
+ async hasCache(key) {
137
+ if (this.cache) {
138
+ return this.cache.has(key);
139
+ }
122
140
  const cached = _cache.get(key);
123
141
  return cached ? cached.expires > Date.now() : false;
124
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -34,13 +34,21 @@
34
34
  "url": "https://github.com/saytohenry/fuzionx"
35
35
  },
36
36
  "dependencies": {
37
- "@fuzionx/core": "^0.1.61",
37
+ "@fuzionx/core": "^0.1.62",
38
38
  "better-sqlite3": "^12.8.0",
39
39
  "knex": "^3.2.5",
40
40
  "mongoose": "^9.3.2",
41
41
  "mysql2": "^3.20.0",
42
42
  "pg": "^8.20.0"
43
43
  },
44
+ "peerDependencies": {
45
+ "ioredis": "^5.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "ioredis": {
49
+ "optional": true
50
+ }
51
+ },
44
52
  "devDependencies": {
45
53
  "vitest": "^3.0.0"
46
54
  },