@fuzionx/framework 0.1.2

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 (45) hide show
  1. package/bin/fx.js +12 -0
  2. package/index.js +64 -0
  3. package/lib/core/AppError.js +46 -0
  4. package/lib/core/Application.js +553 -0
  5. package/lib/core/AutoLoader.js +162 -0
  6. package/lib/core/Base.js +64 -0
  7. package/lib/core/Config.js +122 -0
  8. package/lib/core/Context.js +429 -0
  9. package/lib/database/ConnectionManager.js +192 -0
  10. package/lib/database/MariaModel.js +29 -0
  11. package/lib/database/Model.js +247 -0
  12. package/lib/database/ModelRegistry.js +72 -0
  13. package/lib/database/MongoModel.js +232 -0
  14. package/lib/database/Pagination.js +37 -0
  15. package/lib/database/PostgreModel.js +29 -0
  16. package/lib/database/QueryBuilder.js +172 -0
  17. package/lib/database/SQLiteModel.js +27 -0
  18. package/lib/database/SqlModel.js +252 -0
  19. package/lib/database/SqlQueryBuilder.js +309 -0
  20. package/lib/helpers/CryptoHelper.js +48 -0
  21. package/lib/helpers/FileHelper.js +61 -0
  22. package/lib/helpers/HashHelper.js +39 -0
  23. package/lib/helpers/I18nHelper.js +170 -0
  24. package/lib/helpers/Logger.js +105 -0
  25. package/lib/helpers/MediaHelper.js +38 -0
  26. package/lib/http/Controller.js +34 -0
  27. package/lib/http/ErrorHandler.js +135 -0
  28. package/lib/http/Middleware.js +43 -0
  29. package/lib/http/Router.js +109 -0
  30. package/lib/http/Validation.js +124 -0
  31. package/lib/middleware/index.js +286 -0
  32. package/lib/realtime/RoomManager.js +85 -0
  33. package/lib/realtime/WsHandler.js +107 -0
  34. package/lib/schedule/Job.js +34 -0
  35. package/lib/schedule/Queue.js +90 -0
  36. package/lib/schedule/Scheduler.js +161 -0
  37. package/lib/schedule/Task.js +39 -0
  38. package/lib/schedule/WorkerPool.js +225 -0
  39. package/lib/services/EventBus.js +94 -0
  40. package/lib/services/Service.js +261 -0
  41. package/lib/services/Storage.js +112 -0
  42. package/lib/view/OpenAPI.js +231 -0
  43. package/lib/view/View.js +72 -0
  44. package/package.json +52 -0
  45. package/testing/index.js +232 -0
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Context — 요청/응답 통합 API
3
+ *
4
+ * Bridge rawReq → Context 변환.
5
+ * 미들웨어/핸들러에 전달되는 단일 객체.
6
+ *
7
+ * Session은 Bridge sessionSet/Destroy/Renew N-API에 위임.
8
+ * Cookie는 rawReq.headers.cookie 파싱 + Set-Cookie 헤더 생성.
9
+ *
10
+ * @see docs/framework/06-context.md
11
+ * @see packages/fuzionx/lib/context.js (Core SessionProto 참조)
12
+ */
13
+ import AppError, { ValidationError } from '../core/AppError.js';
14
+
15
+ export default class Context {
16
+ /**
17
+ * @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
18
+ * @param {import('./Application.js').default} app
19
+ */
20
+ constructor(rawReq, app) {
21
+ // ── 요청 프로퍼티 ──
22
+ this.method = rawReq.method || 'GET';
23
+ this.url = rawReq.url || '/';
24
+ this.path = rawReq.path || this.url.split('?')[0];
25
+ this.query = rawReq.query || {};
26
+ this.params = rawReq.params || {};
27
+ this.headers = rawReq.headers || {};
28
+ this.body = rawReq.body || null;
29
+ this.ip = rawReq.remoteIp || '';
30
+ this.files = rawReq.files || null;
31
+ this.formFields = rawReq.formFields || null;
32
+ this.handlerId = rawReq.handlerId;
33
+
34
+ // 파생 속성 (06-context.md)
35
+ this.protocol = this.headers['x-forwarded-proto'] || 'http';
36
+ this.host = this.headers['host'] || '';
37
+
38
+ // ── 프레임워크 주입 ──
39
+ this.app = app;
40
+ this.user = null;
41
+ this._sessionId = rawReq.sessionId || null;
42
+ this._rawSession = rawReq.session || {};
43
+ // ── Cookie (parseCookies 먼저 — _detectLocale에서 참조) ──
44
+ this.cookies = this._parseCookies();
45
+
46
+ this.session = this._createSession(rawReq, app);
47
+ this.locale = this._detectLocale(rawReq);
48
+
49
+ // i18n — ctx.t() + ctx.t.all() (18-i18n.md)
50
+ this.t = this._createT();
51
+
52
+ // ── lazy 캐시 ──
53
+ this._json = undefined;
54
+ this._setCookies = []; // Set-Cookie 헤더 배열
55
+
56
+ // ── 응답 상태 ──
57
+ this._statusCode = 200;
58
+ this._headers = {};
59
+ this._body = '';
60
+ this._sent = false;
61
+ }
62
+
63
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
64
+ // Request 유틸
65
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66
+
67
+ /** lazy JSON 파싱 */
68
+ get getJson() {
69
+ if (this._json !== undefined) return this._json;
70
+ if (typeof this.body === 'object' && this.body !== null) {
71
+ this._json = this.body;
72
+ } else if (typeof this.body === 'string' && this.body) {
73
+ try { this._json = JSON.parse(this.body); }
74
+ catch { this._json = null; }
75
+ } else {
76
+ this._json = null;
77
+ }
78
+ return this._json;
79
+ }
80
+
81
+ /** 헤더 값 가져오기 (case-insensitive) */
82
+ get(name) {
83
+ return this.headers[name.toLowerCase()];
84
+ }
85
+
86
+ /** Content-Type 확인 */
87
+ is(type) {
88
+ const ct = this.get('content-type') || '';
89
+ const map = {
90
+ json: 'application/json',
91
+ html: 'text/html',
92
+ form: 'application/x-www-form-urlencoded',
93
+ multipart: 'multipart/form-data',
94
+ };
95
+ return ct.includes(map[type] || type);
96
+ }
97
+
98
+ /**
99
+ * Accept 협상 (06-context.md)
100
+ * @param {...string} types - 'json', 'html', 'text'
101
+ * @returns {string|false} 매칭된 타입 또는 false
102
+ */
103
+ accepts(...types) {
104
+ const accept = this.get('accept') || '*/*';
105
+ const mimeMap = {
106
+ json: 'application/json',
107
+ html: 'text/html',
108
+ text: 'text/plain',
109
+ xml: 'application/xml',
110
+ };
111
+ for (const type of types) {
112
+ const mime = mimeMap[type] || type;
113
+ if (accept.includes(mime) || accept.includes('*/*')) return type;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
119
+ // Cookie (06-context.md)
120
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
+
122
+ /**
123
+ * 쿠키 읽기/쓰기
124
+ * @overload cookie(name) → 읽기
125
+ * @overload cookie(name, value, opts?) → 쓰기
126
+ */
127
+ cookie(name, value, opts) {
128
+ if (arguments.length === 1) {
129
+ return this.cookies[name] ?? null;
130
+ }
131
+ // 쓰기 → Set-Cookie 헤더
132
+ const o = opts || {};
133
+ let str = `${name}=${encodeURIComponent(String(value))}`;
134
+ if (o.maxAge != null) str += `; Max-Age=${o.maxAge}`;
135
+ if (o.path) str += `; Path=${o.path}`;
136
+ else str += '; Path=/';
137
+ if (o.domain) str += `; Domain=${o.domain}`;
138
+ if (o.httpOnly !== false) str += '; HttpOnly';
139
+ if (o.secure) str += '; Secure';
140
+ if (o.sameSite) str += `; SameSite=${o.sameSite}`;
141
+ this._setCookies.push(str);
142
+ return this;
143
+ }
144
+
145
+ /** 쿠키 삭제 */
146
+ clearCookie(name, opts) {
147
+ return this.cookie(name, '', { ...opts, maxAge: 0 });
148
+ }
149
+
150
+ /** @private */
151
+ _parseCookies() {
152
+ const header = this.headers?.cookie || this.headers?.Cookie || '';
153
+ if (!header) return {};
154
+ const result = {};
155
+ for (const pair of header.split(';')) {
156
+ const [k, ...v] = pair.trim().split('=');
157
+ if (k) result[k.trim()] = decodeURIComponent(v.join('='));
158
+ }
159
+ return result;
160
+ }
161
+
162
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
163
+ // Response 메서드
164
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
165
+
166
+ status(code) {
167
+ this._statusCode = code;
168
+ return this;
169
+ }
170
+
171
+ json(data) {
172
+ this._body = JSON.stringify(data);
173
+ this._headers['Content-Type'] = 'application/json';
174
+ this._sent = true;
175
+ return this;
176
+ }
177
+
178
+ send(text) {
179
+ this._body = String(text);
180
+ this._headers['Content-Type'] = 'text/html; charset=utf-8';
181
+ this._sent = true;
182
+ return this;
183
+ }
184
+
185
+ html(content) {
186
+ return this.send(content);
187
+ }
188
+
189
+ text(content) {
190
+ this._body = String(content);
191
+ this._headers['Content-Type'] = 'text/plain; charset=utf-8';
192
+ this._sent = true;
193
+ return this;
194
+ }
195
+
196
+ redirect(url, code = 302) {
197
+ this._statusCode = code;
198
+ this._headers['Location'] = url;
199
+ this._body = '';
200
+ this._sent = true;
201
+ return this;
202
+ }
203
+
204
+ back() {
205
+ const referer = this.get('referer') || '/';
206
+ return this.redirect(referer);
207
+ }
208
+
209
+ end() {
210
+ this._body = '';
211
+ this._sent = true;
212
+ return this;
213
+ }
214
+
215
+ header(key, value) {
216
+ this._headers[key] = value;
217
+ return this;
218
+ }
219
+
220
+ /** setHeader alias (06-context.md) */
221
+ setHeader(key, value) {
222
+ return this.header(key, value);
223
+ }
224
+
225
+ error(statusCode, message) {
226
+ throw new AppError(message, statusCode);
227
+ }
228
+
229
+ /**
230
+ * 파일 다운로드 (06-context.md)
231
+ * @param {string} filePath
232
+ * @param {string} [filename]
233
+ */
234
+ download(filePath, filename) {
235
+ const name = filename || filePath.split('/').pop();
236
+ this._headers['Content-Disposition'] = `attachment; filename="${name}"`;
237
+ this._body = filePath; // Application에서 파일 전송 처리
238
+ this._sent = true;
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * 스트리밍 응답 (06-context.md)
244
+ * 대용량 응답 시 ReadableStream/Buffer를 직접 전송.
245
+ * @param {ReadableStream|Buffer} readableStream
246
+ * @param {string} [contentType='application/octet-stream']
247
+ */
248
+ stream(readableStream, contentType = 'application/octet-stream') {
249
+ this._headers['Content-Type'] = contentType;
250
+ this._streamBody = readableStream;
251
+ this._sent = true;
252
+ return this;
253
+ }
254
+
255
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
256
+ // Validate
257
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
258
+
259
+ validate(schema) {
260
+ if (schema && typeof schema.validate === 'function') {
261
+ const { error, value } = schema.validate(this.body, { abortEarly: false, stripUnknown: true });
262
+ if (error) {
263
+ const fields = {};
264
+ for (const detail of error.details) {
265
+ fields[detail.path.join('.')] = detail.message;
266
+ }
267
+ throw new ValidationError('Validation failed', fields);
268
+ }
269
+ return value;
270
+ }
271
+ return this.body;
272
+ }
273
+
274
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
275
+ // i18n (Bridge 연동)
276
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
277
+
278
+ /** @private Setup i18n t() function with .all() support */
279
+ _createT() {
280
+ const self = this;
281
+ const t = (key, vars) => {
282
+ const locale = vars?.locale || self.locale;
283
+ if (self.app?.i18n) {
284
+ return self.app.i18n.translate(locale, key, vars);
285
+ }
286
+ return vars?.default || key;
287
+ };
288
+ // ctx.t.all() — 현재 locale 전체 번역 데이터 (18-i18n.md)
289
+ t.all = () => {
290
+ if (self.app?.i18n?.getAll) {
291
+ return self.app.i18n.getAll(self.locale);
292
+ }
293
+ return {};
294
+ };
295
+ return t;
296
+ }
297
+
298
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
299
+ // View 렌더링
300
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
301
+
302
+ render(view, data) {
303
+ // 글로벌 변수 주입 (03-views-templates.md)
304
+ const globals = {
305
+ session: this._rawSession,
306
+ auth: { user: this.user },
307
+ config: this.app?.config?._raw || {},
308
+ request: { url: this.url, method: this.method, path: this.path, ip: this.ip },
309
+ csrf_token: this._rawSession?._csrfToken || '',
310
+ flash: this.session?.getFlash() || {},
311
+ theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
312
+ locale: this.locale,
313
+ ...data,
314
+ };
315
+
316
+ // Bridge SSR (i18n.render → bridge.ssrRenderString)
317
+ if (this.app?._bridge && typeof this.app._bridge.ssrRenderString === 'function') {
318
+ try {
319
+ const html = this.app._bridge.ssrRenderString(view, globals, this.locale);
320
+ return this.html(html);
321
+ } catch {} // Bridge SSR 실패 시 View 폴백
322
+ }
323
+
324
+ if (this.app?._view) {
325
+ const html = this.app._view.render(view, globals);
326
+ return this.html(html);
327
+ }
328
+ return this.send(`View '${view}' not found`);
329
+ }
330
+
331
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
332
+ // Session + Flash (Bridge sessionSet/Destroy/Renew)
333
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
334
+
335
+ /** @private */
336
+ _createSession(rawReq, app) {
337
+ const data = rawReq.session || {};
338
+ const flash = { ...(data._flash || {}) };
339
+ delete data._flash;
340
+ const sessionId = rawReq.sessionId || null;
341
+ const bridge = app?._bridge || null;
342
+
343
+ return {
344
+ /** 세션 값 조회 */
345
+ get: (key) => key ? (data[key] ?? null) : { ...data },
346
+
347
+ /** 세션 값 설정 — Bridge sessionSet 연동 */
348
+ set: (key, value) => {
349
+ data[key] = value;
350
+ // Core SessionProto 참조: bridge.sessionSet(id, { ...data })
351
+ if (sessionId && bridge?.sessionSet) {
352
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
353
+ }
354
+ },
355
+
356
+ /** 세션 삭제 — Bridge sessionDestroy 연동 */
357
+ destroy: () => {
358
+ Object.keys(data).forEach(k => delete data[k]);
359
+ if (sessionId && bridge?.sessionDestroy) {
360
+ try { bridge.sessionDestroy(sessionId); } catch {}
361
+ }
362
+ },
363
+
364
+ /** 세션 갱신 — Bridge sessionRenew 연동 */
365
+ renew: () => {
366
+ if (sessionId && bridge?.sessionRenew) {
367
+ try { return bridge.sessionRenew(sessionId); } catch {}
368
+ }
369
+ return null;
370
+ },
371
+
372
+ /** 플래시 메시지 설정 */
373
+ flash: (key, value) => {
374
+ if (!data._flash) data._flash = {};
375
+ data._flash[key] = value;
376
+ // 플래시도 세션에 영속화
377
+ if (sessionId && bridge?.sessionSet) {
378
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
379
+ }
380
+ },
381
+
382
+ /** 플래시 메시지 조회 (1회 후 삭제) */
383
+ getFlash: (key) => {
384
+ if (key) {
385
+ const val = flash[key];
386
+ delete flash[key];
387
+ return val ?? null;
388
+ }
389
+ const all = { ...flash };
390
+ Object.keys(flash).forEach(k => delete flash[k]);
391
+ return all;
392
+ },
393
+ };
394
+ }
395
+
396
+ /** locale 감지 (5단계 우선순위) — 06-context.md */
397
+ _detectLocale(rawReq) {
398
+ if (rawReq.locale) return rawReq.locale;
399
+ if (this.query?.lang) return this.query.lang;
400
+ // 쿠키 fuzionx.lang
401
+ const cookieLang = this.cookies?.['fuzionx.lang'];
402
+ if (cookieLang) return cookieLang;
403
+ if (this._rawSession?.locale) return this._rawSession.locale;
404
+ const al = this.headers?.['accept-language'];
405
+ if (al) {
406
+ const first = al.split(',')[0]?.split('-')[0];
407
+ if (first) return first;
408
+ }
409
+ return this.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
410
+ }
411
+
412
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
413
+ // 응답 변환
414
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
415
+
416
+ /** Bridge 응답 포맷 { status, body, headers } */
417
+ toResponse() {
418
+ // Set-Cookie 헤더 병합
419
+ if (this._setCookies.length > 0) {
420
+ this._headers['Set-Cookie'] = this._setCookies; // 배열 → Bridge 다중 헤더
421
+ }
422
+ return {
423
+ status: this._statusCode,
424
+ body: this._streamBody || this._body,
425
+ headers: this._headers,
426
+ stream: !!this._streamBody,
427
+ };
428
+ }
429
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * ConnectionManager — DB 연결 관리 싱글톤
3
+ *
4
+ * 드라이버별 연결 생성/캐싱/종료.
5
+ * SQLite (기본), MariaDB, PostgreSQL, MongoDB 지원.
6
+ *
7
+ * @see docs/framework/02-database-orm.md
8
+ * @see docs/framework/17-config.md
9
+ */
10
+ import { createRequire } from 'node:module';
11
+
12
+ const _require = createRequire(import.meta.url);
13
+
14
+ /**
15
+ * optional dependency 안전 로드
16
+ * @private
17
+ */
18
+ function tryRequire(name) {
19
+ try { return _require(name); } catch { return null; }
20
+ }
21
+
22
+ export default class ConnectionManager {
23
+ constructor() {
24
+ /** @type {Map<string, object>} name → connection */
25
+ this._connections = new Map();
26
+ /** @type {Map<string, object>} name → config */
27
+ this._configs = new Map();
28
+ /** @type {string} default connection name */
29
+ this._default = 'main';
30
+ }
31
+
32
+ /**
33
+ * 설정으로 초기화
34
+ * @param {object} dbConfig - fuzionx.yaml database 섹션
35
+ */
36
+ configure(dbConfig) {
37
+ this._default = dbConfig.default || 'main';
38
+ const connections = dbConfig.connections || {};
39
+ for (const [name, cfg] of Object.entries(connections)) {
40
+ this._configs.set(name, cfg);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 연결 가져오기 (lazy 생성)
46
+ * @param {string} [name] - connection name (default: _default)
47
+ * @returns {object} driver connection instance
48
+ */
49
+ get(name) {
50
+ const connName = name || this._default;
51
+ if (this._connections.has(connName)) {
52
+ return this._connections.get(connName);
53
+ }
54
+
55
+ const config = this._configs.get(connName);
56
+ if (!config) {
57
+ throw new Error(`DB connection '${connName}' not configured. Check fuzionx.yaml database.connections.`);
58
+ }
59
+
60
+ const conn = this._createConnection(connName, config);
61
+ this._connections.set(connName, conn);
62
+ return conn;
63
+ }
64
+
65
+ /**
66
+ * 드라이버 타입 조회
67
+ * @param {string} [name]
68
+ * @returns {string} 'sqlite' | 'mariadb' | 'postgres' | 'mongodb'
69
+ */
70
+ getDriver(name) {
71
+ const connName = name || this._default;
72
+ const config = this._configs.get(connName);
73
+ return config?.driver || 'sqlite';
74
+ }
75
+
76
+ /**
77
+ * 드라이버별 연결 생성
78
+ * @private
79
+ */
80
+ _createConnection(name, config) {
81
+ const driver = (config.driver || 'sqlite').toLowerCase();
82
+
83
+ switch (driver) {
84
+ case 'sqlite': {
85
+ const Database = tryRequire('better-sqlite3');
86
+ if (!Database) return this._createStub('sqlite', config);
87
+
88
+ const dbPath = config.database || config.path || ':memory:';
89
+ const db = new Database(dbPath, {
90
+ verbose: config.verbose ? console.log : undefined,
91
+ });
92
+ db.pragma('journal_mode = WAL');
93
+ db.pragma('foreign_keys = ON');
94
+ return { type: 'sqlite', db, config };
95
+ }
96
+
97
+ case 'mariadb':
98
+ case 'mysql': {
99
+ const knex = tryRequire('knex');
100
+ if (!knex) return this._createStub('knex', config, 'mariadb');
101
+
102
+ const knexInit = typeof knex === 'function' ? knex : knex.default;
103
+ const db = knexInit({
104
+ client: 'mysql2',
105
+ connection: {
106
+ host: config.host || '127.0.0.1',
107
+ port: config.port || 3306,
108
+ user: config.user || 'root',
109
+ password: config.password || '',
110
+ database: config.database || '',
111
+ charset: config.charset || 'utf8mb4',
112
+ },
113
+ pool: config.pool || { min: 2, max: 10 },
114
+ });
115
+ return { type: 'knex', driver: 'mariadb', db, config };
116
+ }
117
+
118
+ case 'postgres':
119
+ case 'postgresql': {
120
+ const knex = tryRequire('knex');
121
+ if (!knex) return this._createStub('knex', config, 'postgres');
122
+
123
+ const knexInit = typeof knex === 'function' ? knex : knex.default;
124
+ const db = knexInit({
125
+ client: 'pg',
126
+ connection: {
127
+ host: config.host || '127.0.0.1',
128
+ port: config.port || 5432,
129
+ user: config.user || 'postgres',
130
+ password: config.password || '',
131
+ database: config.database || '',
132
+ },
133
+ pool: config.pool || { min: 2, max: 10 },
134
+ });
135
+ return { type: 'knex', driver: 'postgres', db, config };
136
+ }
137
+
138
+ case 'mongodb':
139
+ case 'mongo': {
140
+ const mongoose = tryRequire('mongoose');
141
+ if (!mongoose) return this._createStub('mongo', config);
142
+ return { type: 'mongo', mongoose, config };
143
+ }
144
+
145
+ default:
146
+ throw new Error(`Unsupported DB driver: '${driver}'. Supported: sqlite, mariadb, postgres, mongodb.`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * 드라이버 미설치 stub
152
+ * @private
153
+ */
154
+ _createStub(type, config, driver) {
155
+ return { type, driver: driver || type, db: null, config, _stub: true };
156
+ }
157
+
158
+ /**
159
+ * 모든 연결 종료 (graceful shutdown)
160
+ */
161
+ async closeAll() {
162
+ for (const [name, conn] of this._connections) {
163
+ try {
164
+ if (conn.type === 'sqlite' && conn.db) {
165
+ conn.db.close();
166
+ } else if (conn.type === 'knex' && conn.db) {
167
+ await conn.db.destroy();
168
+ } else if (conn.type === 'mongo' && conn.mongoose) {
169
+ await conn.mongoose.disconnect();
170
+ }
171
+ } catch (err) {
172
+ console.error(`[ConnectionManager] Error closing '${name}':`, err.message);
173
+ }
174
+ }
175
+ this._connections.clear();
176
+ }
177
+
178
+ /**
179
+ * 특정 연결 종료
180
+ * @param {string} name
181
+ */
182
+ async close(name) {
183
+ const conn = this._connections.get(name);
184
+ if (!conn) return;
185
+ try {
186
+ if (conn.type === 'sqlite' && conn.db) conn.db.close();
187
+ else if (conn.type === 'knex' && conn.db) await conn.db.destroy();
188
+ else if (conn.type === 'mongo' && conn.mongoose) await conn.mongoose.disconnect();
189
+ } catch {}
190
+ this._connections.delete(name);
191
+ }
192
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MariaModel — MariaDB/MySQL 모델 서브클래스
3
+ *
4
+ * Knex + mysql2 기반.
5
+ * SqlModel의 공통 SQL 로직을 상속하며,
6
+ * MariaDB 고유 기능(FULLTEXT, JSON 등)은 raw()로 접근.
7
+ *
8
+ * @see docs/framework/02-database-orm.md
9
+ *
10
+ * @example
11
+ * import { MariaModel } from '@fuzionx/framework';
12
+ *
13
+ * export default class User extends MariaModel {
14
+ * static table = 'users';
15
+ * static connection = 'main';
16
+ * static timestamps = true;
17
+ * static columns = {
18
+ * id: { type: 'increments' },
19
+ * name: { type: 'string', length: 100 },
20
+ * email: { type: 'string', length: 150, unique: true },
21
+ * };
22
+ * }
23
+ */
24
+ import SqlModel from './SqlModel.js';
25
+
26
+ export default class MariaModel extends SqlModel {
27
+ static driver = 'mariadb';
28
+ static connection = 'main';
29
+ }