@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.
- package/cli/index.js +517 -59
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -1
- package/index.js +2 -1
- package/lib/cache/CacheManager.js +183 -0
- package/lib/cache/drivers/FileDriver.js +228 -0
- package/lib/cache/drivers/RedisDriver.js +166 -0
- package/lib/core/Application.js +26 -1
- package/lib/core/Base.js +3 -0
- package/lib/database/ConnectionManager.js +22 -3
- package/lib/database/Model.js +15 -1
- package/lib/database/SqlModel.js +14 -0
- package/lib/database/SqlQueryBuilder.js +91 -6
- package/lib/middleware/index.js +1 -0
- package/lib/middleware/roleGuard.js +49 -0
- package/lib/middleware/theme.js +56 -3
- package/lib/services/Service.js +23 -5
- package/package.json +10 -2
package/cli/index.js
CHANGED
|
@@ -21,10 +21,98 @@ import { promises as fs } from 'node:fs';
|
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 프로젝트 CWD 기준 require — CLI가 심링크된 프레임워크 내부에서 실행되므로
|
|
28
|
+
* 프로젝트의 node_modules를 참조하기 위해 CWD 기반 require 사용
|
|
29
|
+
* @param {string} pkg - 패키지명
|
|
30
|
+
* @returns {*} 모듈
|
|
31
|
+
*/
|
|
32
|
+
function _cwdRequire(pkg) {
|
|
33
|
+
const req = createRequire(path.resolve(process.cwd(), 'package.json'));
|
|
34
|
+
return req(pkg);
|
|
35
|
+
}
|
|
24
36
|
|
|
25
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
38
|
const TPL_DIR = path.join(__dirname, 'templates');
|
|
27
39
|
|
|
40
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
41
|
+
// Knex 컬럼 빌더 — db:sync에서 사용
|
|
42
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Knex table builder에 컬럼 정의를 추가하는 헬퍼
|
|
46
|
+
*
|
|
47
|
+
* 모델의 columns 정의 → Knex 스키마 빌더 호출로 변환.
|
|
48
|
+
* PostgreSQL, MariaDB/MySQL 모두 지원.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('knex').Knex.TableBuilder} t - Knex 테이블 빌더
|
|
51
|
+
* @param {string} col - 컬럼 이름
|
|
52
|
+
* @param {object} def - 컬럼 정의 객체 ({ type, length, nullable, unique, default })
|
|
53
|
+
*/
|
|
54
|
+
function _knexColumnBuilder(t, col, def) {
|
|
55
|
+
let c;
|
|
56
|
+
switch (def.type) {
|
|
57
|
+
case 'increments':
|
|
58
|
+
c = t.increments(col);
|
|
59
|
+
return; // increments는 자동으로 PK + NOT NULL
|
|
60
|
+
case 'integer':
|
|
61
|
+
c = t.integer(col);
|
|
62
|
+
break;
|
|
63
|
+
case 'bigInteger':
|
|
64
|
+
c = t.bigInteger(col);
|
|
65
|
+
break;
|
|
66
|
+
case 'string':
|
|
67
|
+
c = t.string(col, def.length || 255);
|
|
68
|
+
break;
|
|
69
|
+
case 'text':
|
|
70
|
+
c = t.text(col);
|
|
71
|
+
break;
|
|
72
|
+
case 'boolean':
|
|
73
|
+
c = t.boolean(col);
|
|
74
|
+
break;
|
|
75
|
+
case 'datetime':
|
|
76
|
+
case 'timestamp':
|
|
77
|
+
c = t.timestamp(col, { precision: 3 }); // 밀리초 정밀도
|
|
78
|
+
break;
|
|
79
|
+
case 'date':
|
|
80
|
+
c = t.date(col);
|
|
81
|
+
break;
|
|
82
|
+
case 'json':
|
|
83
|
+
case 'jsonb':
|
|
84
|
+
c = t.jsonb(col); // PostgreSQL JSONB, MariaDB JSON
|
|
85
|
+
break;
|
|
86
|
+
case 'float':
|
|
87
|
+
c = t.float(col);
|
|
88
|
+
break;
|
|
89
|
+
case 'decimal':
|
|
90
|
+
c = t.decimal(col, def.precision || 10, def.scale || 2);
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
c = t.string(col, def.length || 255);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// nullable / not null
|
|
97
|
+
if (def.nullable) {
|
|
98
|
+
c.nullable();
|
|
99
|
+
} else {
|
|
100
|
+
c.notNullable();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 기본값
|
|
104
|
+
if (def.default !== undefined) {
|
|
105
|
+
c.defaultTo(def.default);
|
|
106
|
+
} else if ((def.type === 'integer' || def.type === 'bigInteger') && !def.nullable) {
|
|
107
|
+
c.defaultTo(0); // non-nullable integer만 0 기본값 (nullable FK는 NULL 유지)
|
|
108
|
+
} else if (def.type === 'boolean') {
|
|
109
|
+
c.defaultTo(false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// unique 제약
|
|
113
|
+
if (def.unique) c.unique();
|
|
114
|
+
}
|
|
115
|
+
|
|
28
116
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
29
117
|
// Template Engine
|
|
30
118
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -410,92 +498,396 @@ export async function run(args) {
|
|
|
410
498
|
return;
|
|
411
499
|
}
|
|
412
500
|
|
|
413
|
-
// ── fx db:sync — 모델 ↔ DB 스키마
|
|
501
|
+
// ── fx db:sync — 모델 ↔ DB 스키마 동기화 (멀티 드라이버) ──
|
|
414
502
|
if (command === 'db:sync') {
|
|
415
503
|
try {
|
|
416
504
|
const modelsDir = path.resolve('database/models');
|
|
417
505
|
const files = await fs.readdir(modelsDir);
|
|
418
|
-
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
506
|
+
const jsFiles = files.filter(f => f.endsWith('.js') && !f.startsWith('.'));
|
|
419
507
|
const apply = rest.includes('--apply');
|
|
420
508
|
|
|
421
|
-
// fuzionx.yaml
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
509
|
+
// fuzionx.yaml 파싱 — 드라이버 감지
|
|
510
|
+
const { default: Config } = await import('../lib/core/Config.js');
|
|
511
|
+
const config = new Config({});
|
|
512
|
+
config.loadYaml(path.resolve('fuzionx.yaml'));
|
|
513
|
+
const dbConfig = config.get('database') || {};
|
|
514
|
+
const defaultConn = dbConfig.default || 'main';
|
|
515
|
+
const connConfig = dbConfig.connections?.[defaultConn] || {};
|
|
516
|
+
const driver = (connConfig.driver || 'sqlite').toLowerCase();
|
|
428
517
|
|
|
429
|
-
console.log(
|
|
518
|
+
console.log(`\n📊 Model Schema Status (driver: ${driver}):\n`);
|
|
430
519
|
const models = [];
|
|
431
520
|
|
|
432
521
|
for (const file of jsFiles) {
|
|
433
522
|
const mod = await import(pathToFileURL(path.join(modelsDir, file)).href);
|
|
434
523
|
const Model = mod.default;
|
|
435
524
|
if (!Model) continue;
|
|
525
|
+
// 모델의 드라이버가 해당 커넥션과 일치하는 것만 처리
|
|
526
|
+
const modelDriver = (Model.driver || 'sqlite').toLowerCase();
|
|
527
|
+
if (modelDriver !== driver && modelDriver !== 'postgres' && driver !== 'postgres') continue;
|
|
528
|
+
if (modelDriver === 'mongodb' || modelDriver === 'mongo') continue; // MongoDB 모델 제외
|
|
436
529
|
const name = path.basename(file, '.js');
|
|
437
530
|
const table = Model.table || name.toLowerCase() + 's';
|
|
438
531
|
const cols = Model.columns || {};
|
|
439
|
-
|
|
532
|
+
const indexes = Model.indexes || []; // 인덱스 정의 배열
|
|
533
|
+
models.push({ name, table, cols, indexes, Model });
|
|
440
534
|
}
|
|
441
535
|
|
|
442
536
|
if (apply) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
537
|
+
if (driver === 'sqlite') {
|
|
538
|
+
// ── SQLite ──
|
|
539
|
+
const dbPath = connConfig.database || connConfig.path || './storage/database.sqlite';
|
|
540
|
+
const resolvedDbPath = path.resolve(dbPath);
|
|
541
|
+
await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
|
|
542
|
+
const { default: Database } = await import('better-sqlite3').catch(() => ({ default: _cwdRequire('better-sqlite3') }));
|
|
543
|
+
const db = new Database(resolvedDbPath);
|
|
544
|
+
|
|
545
|
+
for (const { name, table, cols } of models) {
|
|
546
|
+
const colDefs = [];
|
|
547
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
548
|
+
if (def.type === 'increments') {
|
|
549
|
+
colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
|
|
550
|
+
} else if (def.type === 'integer') {
|
|
551
|
+
const dflt = def.default != null ? def.default : 0;
|
|
552
|
+
colDefs.push(`\`${col}\` INTEGER${def.nullable ? '' : ' NOT NULL'} DEFAULT ${dflt}`);
|
|
553
|
+
} else if (def.type === 'text') {
|
|
554
|
+
colDefs.push(`\`${col}\` TEXT${def.nullable ? '' : " NOT NULL DEFAULT ''"}`);
|
|
555
|
+
} else if (def.type === 'datetime') {
|
|
556
|
+
colDefs.push(`\`${col}\` DATETIME DEFAULT ${def.nullable !== false ? 'NULL' : 'CURRENT_TIMESTAMP'}`);
|
|
557
|
+
} else if (def.type === 'boolean') {
|
|
558
|
+
const dflt = def.default === true ? 1 : 0;
|
|
559
|
+
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT ${dflt}`);
|
|
560
|
+
} else if (def.type === 'json') {
|
|
561
|
+
colDefs.push(`\`${col}\` TEXT${def.nullable ? '' : " NOT NULL DEFAULT '[]'"}`);
|
|
562
|
+
} else {
|
|
563
|
+
const unique = def.unique ? ' UNIQUE' : '';
|
|
564
|
+
const nullable = def.nullable ? '' : ' NOT NULL';
|
|
565
|
+
const dflt = def.default != null ? ` DEFAULT '${def.default}'` : (def.nullable ? '' : " DEFAULT ''");
|
|
566
|
+
colDefs.push(`\`${col}\` TEXT${nullable}${dflt}${unique}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
|
|
570
|
+
db.exec(sql);
|
|
571
|
+
|
|
572
|
+
// 누락 컬럼 추가
|
|
573
|
+
const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
|
|
574
|
+
let addedCols = 0;
|
|
575
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
576
|
+
if (existingCols.includes(col)) continue;
|
|
577
|
+
let colSql;
|
|
578
|
+
if (def.type === 'integer') colSql = `INTEGER${def.nullable ? '' : ' NOT NULL'} DEFAULT ${def.default ?? 0}`;
|
|
579
|
+
else if (def.type === 'text') colSql = `TEXT${def.nullable ? '' : " NOT NULL DEFAULT ''"}`;
|
|
580
|
+
else if (def.type === 'datetime') colSql = `DATETIME DEFAULT NULL`;
|
|
581
|
+
else if (def.type === 'boolean') colSql = `INTEGER NOT NULL DEFAULT ${def.default === true ? 1 : 0}`;
|
|
582
|
+
else if (def.type === 'json') colSql = `TEXT${def.nullable ? '' : " NOT NULL DEFAULT '[]'"}`;
|
|
583
|
+
else colSql = `TEXT DEFAULT ${def.default != null ? `'${def.default}'` : "''"}`;
|
|
584
|
+
db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
|
|
585
|
+
addedCols++;
|
|
586
|
+
}
|
|
587
|
+
const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
|
|
588
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
|
|
589
|
+
}
|
|
448
590
|
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
591
|
+
// ── SQLite 인덱스 동기화 ──
|
|
592
|
+
for (const { name, table, indexes } of models) {
|
|
593
|
+
if (!indexes || indexes.length === 0) continue;
|
|
594
|
+
for (const idx of indexes) {
|
|
595
|
+
if (!idx.columns || idx.columns.length === 0) continue;
|
|
596
|
+
// 인덱스 이름 자동 생성: idx_{table}_{col1}_{col2}
|
|
597
|
+
const idxName = idx.name || `idx_${table}_${idx.columns.join('_')}`;
|
|
598
|
+
const colList = idx.columns.map(c => `\`${c}\``).join(', ');
|
|
599
|
+
const uniqueStr = idx.unique ? 'UNIQUE ' : '';
|
|
600
|
+
const sql = `CREATE ${uniqueStr}INDEX IF NOT EXISTS \`${idxName}\` ON \`${table}\` (${colList})`;
|
|
601
|
+
try {
|
|
602
|
+
db.exec(sql);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
// 이미 존재하면 무시
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
console.log(` ${name.padEnd(20)} indexes: ${indexes.length} synced`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
db.close();
|
|
611
|
+
console.log(`\n Database: ${path.resolve(connConfig.database || './storage/database.sqlite')}`);
|
|
612
|
+
|
|
613
|
+
} else if (driver === 'postgres' || driver === 'postgresql' || driver === 'mariadb' || driver === 'mysql') {
|
|
614
|
+
// ── Knex 기반 (PostgreSQL / MariaDB) ──
|
|
615
|
+
const knexModule = _cwdRequire('knex');
|
|
616
|
+
const knexInit = typeof knexModule === 'function' ? knexModule : knexModule.knex || knexModule.default;
|
|
617
|
+
const client = (driver === 'postgres' || driver === 'postgresql') ? 'pg' : 'mysql2';
|
|
618
|
+
const db = knexInit({
|
|
619
|
+
client,
|
|
620
|
+
connection: {
|
|
621
|
+
host: connConfig.host || '127.0.0.1',
|
|
622
|
+
port: connConfig.port || (client === 'pg' ? 5432 : 3306),
|
|
623
|
+
user: connConfig.user || (client === 'pg' ? 'postgres' : 'root'),
|
|
624
|
+
password: connConfig.password || '',
|
|
625
|
+
database: connConfig.database || '',
|
|
626
|
+
charset: connConfig.charset || undefined,
|
|
627
|
+
},
|
|
628
|
+
pool: connConfig.pool || { min: 2, max: 10 },
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// DDL SQL 캡처 — migrations 로그용
|
|
632
|
+
const ddlLog = [];
|
|
633
|
+
db.on('query', (data) => {
|
|
634
|
+
const sql = (data.sql || '').trim();
|
|
635
|
+
// DDL 문만 기록 (SELECT/정보조회 제외)
|
|
636
|
+
if (/^(CREATE|ALTER|DROP)/i.test(sql)) {
|
|
637
|
+
// 바인딩 치환 (읽기 편하게)
|
|
638
|
+
let resolved = sql;
|
|
639
|
+
if (data.bindings && data.bindings.length > 0) {
|
|
640
|
+
for (const b of data.bindings) {
|
|
641
|
+
resolved = resolved.replace('?', typeof b === 'string' ? `'${b}'` : String(b));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
ddlLog.push(resolved);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
for (const { name, table, cols } of models) {
|
|
649
|
+
const exists = await db.schema.hasTable(table);
|
|
650
|
+
if (!exists) {
|
|
651
|
+
// 테이블 생성
|
|
652
|
+
await db.schema.createTable(table, (t) => {
|
|
653
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
654
|
+
_knexColumnBuilder(t, col, def);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ✅ created`);
|
|
462
658
|
} else {
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
|
|
659
|
+
// 누락 컬럼 추가
|
|
660
|
+
let addedCols = 0;
|
|
661
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
662
|
+
const hasCol = await db.schema.hasColumn(table, col);
|
|
663
|
+
if (!hasCol) {
|
|
664
|
+
await db.schema.alterTable(table, (t) => {
|
|
665
|
+
_knexColumnBuilder(t, col, def);
|
|
666
|
+
});
|
|
667
|
+
addedCols++;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
|
|
671
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
|
|
467
672
|
}
|
|
468
673
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
674
|
+
|
|
675
|
+
// ── Knex 인덱스 동기화 (PostgreSQL / MariaDB) ──
|
|
676
|
+
for (const { name, table, indexes } of models) {
|
|
677
|
+
if (!indexes || indexes.length === 0) continue;
|
|
678
|
+
let createdCount = 0;
|
|
679
|
+
for (const idx of indexes) {
|
|
680
|
+
if (!idx.columns || idx.columns.length === 0) continue;
|
|
681
|
+
const idxName = idx.name || `idx_${table}_${idx.columns.join('_')}`;
|
|
682
|
+
try {
|
|
683
|
+
// 인덱스 존재 여부 확인
|
|
684
|
+
const checkSql = client === 'pg'
|
|
685
|
+
? `SELECT 1 FROM pg_indexes WHERE tablename = ? AND indexname = ?`
|
|
686
|
+
: `SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1`;
|
|
687
|
+
const exists = await db.raw(checkSql, [table, idxName]);
|
|
688
|
+
const hasIndex = client === 'pg'
|
|
689
|
+
? (exists.rows && exists.rows.length > 0)
|
|
690
|
+
: (Array.isArray(exists[0]) ? exists[0].length > 0 : exists.length > 0);
|
|
691
|
+
if (hasIndex) continue; // 이미 존재하면 스킵
|
|
692
|
+
|
|
693
|
+
await db.schema.alterTable(table, (t) => {
|
|
694
|
+
if (idx.unique) {
|
|
695
|
+
t.unique(idx.columns, { indexName: idxName });
|
|
696
|
+
} else {
|
|
697
|
+
t.index(idx.columns, idxName);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
createdCount++;
|
|
701
|
+
} catch (e) {
|
|
702
|
+
// 이미 존재하는 경우 무시
|
|
703
|
+
if (!e.message.includes('already exists')) {
|
|
704
|
+
console.warn(` ⚠️ Index ${idxName}: ${e.message}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (createdCount > 0) {
|
|
709
|
+
console.log(` ${name.padEnd(20)} indexes: +${createdCount} created`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Knex FK 제약 동기화 (PostgreSQL / MariaDB) ──
|
|
714
|
+
// 모델명 → 테이블명 매핑 (relation resolve용)
|
|
715
|
+
const modelTableMap = new Map();
|
|
716
|
+
for (const { name, table, Model } of models) {
|
|
717
|
+
modelTableMap.set(name, { table, Model });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
let fkCreatedTotal = 0;
|
|
721
|
+
for (const { name, table, Model } of models) {
|
|
722
|
+
const relations = Model.relations || {};
|
|
723
|
+
const columns = Model.columns || {};
|
|
724
|
+
|
|
725
|
+
for (const [relName, rel] of Object.entries(relations)) {
|
|
726
|
+
// belongsTo만 FK 생성 대상 (현재 테이블에 FK 컬럼이 있음)
|
|
727
|
+
if (rel.type !== 'belongsTo') continue;
|
|
728
|
+
|
|
729
|
+
const foreignKey = rel.foreignKey;
|
|
730
|
+
if (!foreignKey) continue;
|
|
731
|
+
|
|
732
|
+
// 대상 모델 → 테이블명 resolve
|
|
733
|
+
const targetModelName = typeof rel.model === 'string' ? rel.model : rel.model?.name;
|
|
734
|
+
const targetInfo = modelTableMap.get(targetModelName);
|
|
735
|
+
if (!targetInfo) continue; // 대상 모델을 찾을 수 없으면 스킵
|
|
736
|
+
|
|
737
|
+
const targetTable = targetInfo.table;
|
|
738
|
+
const targetKey = rel.ownerKey || 'id'; // 대상 테이블의 참조 키
|
|
739
|
+
|
|
740
|
+
// FK 제약명: fk_{테이블}_{FK컬럼}
|
|
741
|
+
const fkName = `fk_${table}_${foreignKey}`;
|
|
742
|
+
|
|
743
|
+
try {
|
|
744
|
+
// FK 이미 존재하는지 확인
|
|
745
|
+
const checkSql = client === 'pg'
|
|
746
|
+
? `SELECT 1 FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY' AND table_name = ? AND constraint_name = ?`
|
|
747
|
+
: `SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE = 'FOREIGN KEY' AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? AND TABLE_SCHEMA = DATABASE()`;
|
|
748
|
+
const fkExists = await db.raw(checkSql, [table, fkName]);
|
|
749
|
+
const hasFk = client === 'pg'
|
|
750
|
+
? (fkExists.rows && fkExists.rows.length > 0)
|
|
751
|
+
: (Array.isArray(fkExists[0]) ? fkExists[0].length > 0 : fkExists.length > 0);
|
|
752
|
+
if (hasFk) continue; // 이미 존재하면 스킵
|
|
753
|
+
|
|
754
|
+
// onDelete 결정: relation에 명시 > nullable이면 SET NULL > 아니면 CASCADE
|
|
755
|
+
const colDef = columns[foreignKey] || {};
|
|
756
|
+
const onDelete = rel.onDelete || (colDef.nullable ? 'SET NULL' : 'CASCADE');
|
|
757
|
+
|
|
758
|
+
await db.schema.alterTable(table, (t) => {
|
|
759
|
+
t.foreign(foreignKey, fkName)
|
|
760
|
+
.references(targetKey)
|
|
761
|
+
.inTable(targetTable)
|
|
762
|
+
.onDelete(onDelete);
|
|
763
|
+
});
|
|
764
|
+
fkCreatedTotal++;
|
|
765
|
+
} catch (e) {
|
|
766
|
+
if (!e.message.includes('already exists')) {
|
|
767
|
+
console.warn(` ⚠️ FK ${fkName}: ${e.message}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (fkCreatedTotal > 0) {
|
|
773
|
+
console.log(`\n 🔗 Foreign Keys: +${fkCreatedTotal} created`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── Migration SQL 파일 저장 (DDL 변경이 있을 때만) ──
|
|
777
|
+
if (ddlLog.length > 0) {
|
|
778
|
+
const migrationsDir = path.resolve('database/migrations');
|
|
779
|
+
await fs.mkdir(migrationsDir, { recursive: true });
|
|
780
|
+
const now = new Date();
|
|
781
|
+
const ts = now.toISOString()
|
|
782
|
+
.replace(/[-:]/g, '') // 20260404T110800
|
|
783
|
+
.replace('T', '_') // 20260404_110800
|
|
784
|
+
.slice(0, 15); // 20260404_110800
|
|
785
|
+
const fileName = `${ts}_sync.sql`;
|
|
786
|
+
const header = [
|
|
787
|
+
`-- FuzionX db:sync migration`,
|
|
788
|
+
`-- Generated: ${now.toISOString()}`,
|
|
789
|
+
`-- Driver: ${driver} (${connConfig.host}:${connConfig.port}/${connConfig.database})`,
|
|
790
|
+
`-- ──────────────────────────────────────`,
|
|
791
|
+
'',
|
|
792
|
+
].join('\n');
|
|
793
|
+
const body = ddlLog.map(sql => `${sql};`).join('\n\n');
|
|
794
|
+
await fs.writeFile(path.join(migrationsDir, fileName), header + body + '\n');
|
|
795
|
+
console.log(`\n 📝 Migration saved: database/migrations/${fileName} (${ddlLog.length} statements)`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
await db.destroy();
|
|
799
|
+
console.log(`\n Database: ${driver}://${connConfig.host}:${connConfig.port}/${connConfig.database}`);
|
|
800
|
+
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── MongoDB 인덱스 동기화 ──
|
|
804
|
+
const mongoModels = [];
|
|
805
|
+
for (const file of jsFiles) {
|
|
806
|
+
const mod = await import(pathToFileURL(path.join(modelsDir, file)).href);
|
|
807
|
+
const Model = mod.default;
|
|
808
|
+
if (!Model) continue;
|
|
809
|
+
const modelDriver = (Model.driver || 'sqlite').toLowerCase();
|
|
810
|
+
if (modelDriver !== 'mongodb' && modelDriver !== 'mongo') continue;
|
|
811
|
+
const name = path.basename(file, '.js');
|
|
812
|
+
const collection = Model.table || Model.collection || name.toLowerCase() + 's';
|
|
813
|
+
const indexes = Model.indexes || [];
|
|
814
|
+
if (indexes.length > 0) {
|
|
815
|
+
mongoModels.push({ name, collection, indexes, Model });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (mongoModels.length > 0) {
|
|
820
|
+
// MongoDB 연결 설정 읽기
|
|
821
|
+
const mongoConfig = dbConfig.connections?.mongo || dbConfig.connections?.mongodb || {};
|
|
822
|
+
const host = mongoConfig.host || '127.0.0.1';
|
|
823
|
+
const port = mongoConfig.port || 27017;
|
|
824
|
+
const dbName = mongoConfig.database || 'fuzionx';
|
|
825
|
+
const authSource = mongoConfig.authSource || dbName;
|
|
826
|
+
// 인증 URI 구성 (user/password가 있으면 포함)
|
|
827
|
+
let mongoUri = mongoConfig.uri || mongoConfig.url;
|
|
828
|
+
if (!mongoUri) {
|
|
829
|
+
if (mongoConfig.user && mongoConfig.password) {
|
|
830
|
+
mongoUri = `mongodb://${encodeURIComponent(String(mongoConfig.user))}:${encodeURIComponent(String(mongoConfig.password))}@${host}:${port}/${dbName}?authSource=${authSource}`;
|
|
487
831
|
} else {
|
|
488
|
-
|
|
489
|
-
colSql = `TEXT DEFAULT ${dflt}`;
|
|
832
|
+
mongoUri = `mongodb://${host}:${port}/${dbName}`;
|
|
490
833
|
}
|
|
491
|
-
db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
|
|
492
|
-
addedCols++;
|
|
493
834
|
}
|
|
494
|
-
const
|
|
495
|
-
|
|
835
|
+
const mongoDbName = dbName;
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
const { MongoClient } = await import('mongodb').catch(() => ({ MongoClient: _cwdRequire('mongodb').MongoClient }));
|
|
839
|
+
const mongoClient = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 5000 });
|
|
840
|
+
await mongoClient.connect();
|
|
841
|
+
const mongoDB = mongoClient.db(mongoDbName);
|
|
842
|
+
|
|
843
|
+
console.log(`\n📊 MongoDB Index Sync:\n`);
|
|
844
|
+
for (const { name, collection, indexes } of mongoModels) {
|
|
845
|
+
const coll = mongoDB.collection(collection);
|
|
846
|
+
let createdCount = 0;
|
|
847
|
+
// 기존 인덱스 목록 조회
|
|
848
|
+
const existingIndexes = await coll.indexes().catch(() => []);
|
|
849
|
+
const existingNames = existingIndexes.map(i => i.name);
|
|
850
|
+
|
|
851
|
+
for (const idx of indexes) {
|
|
852
|
+
if (!idx.columns) continue;
|
|
853
|
+
// columns가 객체({ field: 1 })인지 배열(['field'])인지 판별
|
|
854
|
+
const isObject = !Array.isArray(idx.columns);
|
|
855
|
+
const columnKeys = isObject ? Object.keys(idx.columns) : idx.columns;
|
|
856
|
+
if (columnKeys.length === 0) continue;
|
|
857
|
+
const idxName = idx.name || `idx_${columnKeys.join('_')}`;
|
|
858
|
+
if (existingNames.includes(idxName)) continue; // 이미 존재
|
|
859
|
+
|
|
860
|
+
// MongoDB 인덱스 키 객체 생성 { field1: 1, field2: -1 }
|
|
861
|
+
let keys;
|
|
862
|
+
if (isObject) {
|
|
863
|
+
keys = idx.columns; // 이미 { field: 1, field: -1 } 형태
|
|
864
|
+
} else {
|
|
865
|
+
keys = {};
|
|
866
|
+
for (const col of idx.columns) {
|
|
867
|
+
keys[col] = idx.descending ? -1 : 1;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const options = { name: idxName };
|
|
871
|
+
if (idx.unique) options.unique = true;
|
|
872
|
+
if (idx.sparse) options.sparse = true;
|
|
873
|
+
if (idx.ttl != null) options.expireAfterSeconds = idx.ttl;
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
await coll.createIndex(keys, options);
|
|
877
|
+
createdCount++;
|
|
878
|
+
} catch (e) {
|
|
879
|
+
console.warn(` ⚠️ ${collection}.${idxName}: ${e.message}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const status = createdCount > 0 ? `+${createdCount} created` : 'synced';
|
|
883
|
+
console.log(` ${name.padEnd(20)} collection: ${collection.padEnd(20)} indexes: ${indexes.length} ✅ ${status}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await mongoClient.close();
|
|
887
|
+
} catch (mongoErr) {
|
|
888
|
+
console.warn(` ⚠️ MongoDB 인덱스 동기화 실패: ${mongoErr.message}`);
|
|
889
|
+
}
|
|
496
890
|
}
|
|
497
|
-
db.close();
|
|
498
|
-
console.log(`\n Database: ${path.resolve(dbPath)}`);
|
|
499
891
|
} else {
|
|
500
892
|
for (const { name, table, cols } of models) {
|
|
501
893
|
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
|
|
@@ -507,6 +899,71 @@ export async function run(args) {
|
|
|
507
899
|
return;
|
|
508
900
|
}
|
|
509
901
|
|
|
902
|
+
// ── fx db:seed — 시드 데이터 투입 ──
|
|
903
|
+
if (command === 'db:seed') {
|
|
904
|
+
try {
|
|
905
|
+
const seedDir = path.resolve('database/seeds');
|
|
906
|
+
const indexPath = path.join(seedDir, 'index.js');
|
|
907
|
+
try {
|
|
908
|
+
await fs.access(indexPath);
|
|
909
|
+
} catch {
|
|
910
|
+
console.error('❌ database/seeds/index.js 파일이 없습니다.');
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// DB 연결 부트 — ConnectionManager 초기화
|
|
915
|
+
const { default: Config } = await import('../lib/core/Config.js');
|
|
916
|
+
const ConnectionManager = (await import('../lib/database/ConnectionManager.js')).default;
|
|
917
|
+
|
|
918
|
+
const config = new Config({});
|
|
919
|
+
config.loadYaml(path.resolve('fuzionx.yaml'));
|
|
920
|
+
const dbConfig = config.get('database');
|
|
921
|
+
|
|
922
|
+
if (dbConfig) {
|
|
923
|
+
if (!dbConfig.connections) {
|
|
924
|
+
const connections = {};
|
|
925
|
+
for (const [key, val] of Object.entries(dbConfig)) {
|
|
926
|
+
if (key !== 'default' && typeof val === 'object' && val !== null) {
|
|
927
|
+
connections[key] = val;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (Object.keys(connections).length > 0) {
|
|
931
|
+
dbConfig.connections = connections;
|
|
932
|
+
if (!dbConfig.default) dbConfig.default = Object.keys(connections)[0];
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const cm = new ConnectionManager();
|
|
936
|
+
cm.configure(dbConfig);
|
|
937
|
+
|
|
938
|
+
// CLI 상대경로와 모델의 @fuzionx/framework 경로가 ESM에서 별개 모듈로 캐시됨.
|
|
939
|
+
// 모델이 실제 사용하는 SqlModel에 ConnectionManager를 주입하기 위해
|
|
940
|
+
// 모델 디렉토리에서 첫 번째 모델을 import하여 그 부모 클래스 체인에 주입.
|
|
941
|
+
const modelsDir = path.resolve('database/models');
|
|
942
|
+
const modelFiles = (await fs.readdir(modelsDir)).filter(f => f.endsWith('.js'));
|
|
943
|
+
if (modelFiles.length > 0) {
|
|
944
|
+
const firstModel = (await import(pathToFileURL(path.join(modelsDir, modelFiles[0])).href)).default;
|
|
945
|
+
if (firstModel && typeof firstModel.setConnectionManager === 'function') {
|
|
946
|
+
firstModel.setConnectionManager(cm);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// CLI 자체 SqlModel에도 주입 (혹시 직접 참조하는 경우 대비)
|
|
951
|
+
const SqlModel = (await import('../lib/database/SqlModel.js')).default;
|
|
952
|
+
SqlModel.setConnectionManager(cm);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// 시드 실행
|
|
956
|
+
const seedModule = await import(pathToFileURL(indexPath).href);
|
|
957
|
+
const runSeeds = seedModule.runSeeds || seedModule.default;
|
|
958
|
+
if (typeof runSeeds === 'function') {
|
|
959
|
+
await runSeeds();
|
|
960
|
+
} else {
|
|
961
|
+
console.error('❌ database/seeds/index.js에 runSeeds 함수가 없습니다.');
|
|
962
|
+
}
|
|
963
|
+
} catch (err) { console.error('db:seed error:', err.message, err.stack); }
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
510
967
|
console.log(`
|
|
511
968
|
npx create-fuzionx <name> Create new project
|
|
512
969
|
fx make:app --type=ssr|spa [--name=<appName>] Create app
|
|
@@ -529,5 +986,6 @@ export async function run(args) {
|
|
|
529
986
|
fx routes Print route table
|
|
530
987
|
fx config Print fuzionx.yaml
|
|
531
988
|
fx db:sync Sync models → DB (--apply)
|
|
989
|
+
fx db:seed Run seed files (database/seeds)
|
|
532
990
|
`);
|
|
533
991
|
}
|