@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 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 스키마 diff ──
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에서 DB 경로 읽기
422
- let dbPath = './storage/database.sqlite';
423
- try {
424
- const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
425
- const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
426
- if (dbMatch) dbPath = dbMatch[1].trim();
427
- } catch {}
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('\n📊 Model Schema Status:\n');
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
- models.push({ name, table, cols });
532
+ const indexes = Model.indexes || []; // 인덱스 정의 배열
533
+ models.push({ name, table, cols, indexes, Model });
440
534
  }
441
535
 
442
536
  if (apply) {
443
- // SQLite DB 열기
444
- const resolvedDbPath = path.resolve(dbPath);
445
- await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
446
- const { default: Database } = await import('better-sqlite3');
447
- const db = new Database(resolvedDbPath);
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
- for (const { name, table, cols } of models) {
450
- const colDefs = [];
451
- for (const [col, def] of Object.entries(cols)) {
452
- if (def.type === 'increments') {
453
- colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
454
- } else if (def.type === 'integer') {
455
- colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
456
- } else if (def.type === 'text') {
457
- colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
458
- } else if (def.type === 'datetime') {
459
- colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
460
- } else if (def.type === 'boolean') {
461
- colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
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
- // string, etc.
464
- const unique = def.unique ? ' UNIQUE' : '';
465
- const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
466
- colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
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
- const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
470
- db.exec(sql);
471
-
472
- // ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
473
- const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
474
- let addedCols = 0;
475
- for (const [col, def] of Object.entries(cols)) {
476
- if (existingCols.includes(col)) continue;
477
-
478
- let colSql;
479
- if (def.type === 'integer') {
480
- colSql = `INTEGER NOT NULL DEFAULT 0`;
481
- } else if (def.type === 'text') {
482
- colSql = `TEXT NOT NULL DEFAULT ''`;
483
- } else if (def.type === 'datetime') {
484
- colSql = `DATETIME DEFAULT NULL`;
485
- } else if (def.type === 'boolean') {
486
- colSql = `INTEGER NOT NULL DEFAULT 0`;
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
- const dflt = def.default != null ? `'${def.default}'` : "''";
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 status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
495
- console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
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
  }
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.61",
12
+ "@fuzionx/player": "^0.1.62",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"