create-fuzionx 0.1.34 → 0.1.37

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/index.js CHANGED
@@ -193,7 +193,8 @@ try {
193
193
  cd ${name}
194
194
  npm install
195
195
  ${type === 'spa' ? `cd app/spa/views/default/spa && npm install && cd ../../../../..
196
- ` : ''}${nextCmd}
196
+ ` : ''}npx fx db:sync --apply
197
+ ${nextCmd}
197
198
  `);
198
199
  } catch (err) {
199
200
  console.error(`❌ Failed to create app: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-fuzionx",
3
- "version": "0.1.34",
3
+ "version": "0.1.37",
4
4
  "description": "Create a new FuzionX application — npx create-fuzionx my-app",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,41 @@
1
+ import { SQLiteModel } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * Attachment — 게시글 첨부파일 모델
5
+ *
6
+ * 게시글에 첨부된 파일 메타데이터를 관리.
7
+ * 실제 파일은 storage/uploads/ 에 저장, DB에는 경로만 보관.
8
+ *
9
+ * @extends SQLiteModel
10
+ */
11
+ export default class Attachment extends SQLiteModel {
12
+ /** @type {string} 테이블 명 */
13
+ static table = 'attachments';
14
+
15
+ /** @type {boolean} created_at 자동 관리 */
16
+ static timestamps = true;
17
+
18
+ /**
19
+ * 컬럼 정의
20
+ *
21
+ * @type {Object<string, import('@fuzionx/framework').ColumnDefinition>}
22
+ * @property {Object} id - 자동 증가 기본키 (PK)
23
+ * @property {Object} post_id - 게시글 ID (FK → posts.id)
24
+ * @property {Object} original_name - 원본 파일명
25
+ * @property {Object} file_path - Storage 상대 경로 (uploads/posts/xxx.jpg)
26
+ * @property {Object} mime_type - MIME 타입 (image/jpeg, application/pdf 등)
27
+ * @property {Object} size - 파일 크기 (bytes)
28
+ * @property {Object} created_at - 업로드 일시
29
+ * @property {Object} updated_at - 수정 일시
30
+ */
31
+ static columns = {
32
+ id: { type: 'increments' },
33
+ post_id: { type: 'integer' },
34
+ original_name: { type: 'string', length: 255 },
35
+ file_path: { type: 'string', length: 500 },
36
+ mime_type: { type: 'string', length: 100 },
37
+ size: { type: 'integer' },
38
+ created_at: { type: 'datetime' },
39
+ updated_at: { type: 'datetime' },
40
+ };
41
+ }
@@ -0,0 +1,39 @@
1
+ import { SQLiteModel } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * Post — 게시글 모델
5
+ *
6
+ * 게시판의 게시글 데이터를 관리하는 모델.
7
+ * 사용자(User)와 N:1 관계로, `user_id` 외래키로 연결.
8
+ *
9
+ * @extends SQLiteModel
10
+ */
11
+ export default class Post extends SQLiteModel {
12
+ /** @type {string} 테이블 명 */
13
+ static table = 'posts';
14
+
15
+ /** @type {boolean} created_at / updated_at 자동 관리 */
16
+ static timestamps = true;
17
+
18
+ /**
19
+ * 컬럼 정의
20
+ *
21
+ * @type {Object<string, import('@fuzionx/framework').ColumnDefinition>}
22
+ * @property {Object} id - 자동 증가 기본키 (PK)
23
+ * @property {Object} user_id - 작성자 ID (FK → users.id)
24
+ * @property {Object} title - 게시글 제목 (최대 200자)
25
+ * @property {Object} content - 게시글 본문 (TEXT)
26
+ * @property {Object} status - 상태: 'published' | 'processing'
27
+ * @property {Object} created_at - 생성 일시
28
+ * @property {Object} updated_at - 수정 일시
29
+ */
30
+ static columns = {
31
+ id: { type: 'increments' },
32
+ user_id: { type: 'integer' },
33
+ title: { type: 'string', length: 200 },
34
+ content: { type: 'text' },
35
+ status: { type: 'string', length: 20, default: 'published' },
36
+ created_at: { type: 'datetime' },
37
+ updated_at: { type: 'datetime' },
38
+ };
39
+ }
@@ -0,0 +1,53 @@
1
+ import { SQLiteModel } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * Thumbnail — 첨부파일 썸네일 모델
5
+ *
6
+ * 이미지/비디오 첨부파일의 썸네일 메타데이터.
7
+ * - 이미지: 원본을 리사이즈하여 webp로 저장
8
+ * - 비디오: 3초 지점 프레임 추출 후 리사이즈
9
+ *
10
+ * Attachment 1:1 관계 (attachment_id FK).
11
+ * 실제 파일은 storage/uploads/thumbs/ 에 저장.
12
+ *
13
+ * @example
14
+ * // 썸네일 조회
15
+ * const thumb = await Thumbnail.where('attachment_id', att.id).first();
16
+ * const url = app.storage.url(thumb.file_path);
17
+ *
18
+ * @extends SQLiteModel
19
+ */
20
+ export default class Thumbnail extends SQLiteModel {
21
+ /** @type {string} 테이블 명 */
22
+ static table = 'thumbnails';
23
+
24
+ /** @type {boolean} created_at 자동 관리 */
25
+ static timestamps = true;
26
+
27
+ /**
28
+ * 컬럼 정의
29
+ *
30
+ * @type {Object<string, import('@fuzionx/framework').ColumnDefinition>}
31
+ * @property {Object} id - 자동 증가 PK
32
+ * @property {Object} attachment_id - 첨부파일 ID (FK → attachments.id)
33
+ * @property {Object} file_path - Storage 상대 경로 (uploads/thumbs/xxx.webp)
34
+ * @property {Object} width - 썸네일 가로 크기 (px)
35
+ * @property {Object} height - 썸네일 세로 크기 (px)
36
+ * @property {Object} format - 포맷 (webp, jpeg)
37
+ * @property {Object} size - 파일 크기 (bytes)
38
+ * @property {Object} source_type - 원본 타입 (image|video)
39
+ * @property {Object} created_at - 생성 일시
40
+ */
41
+ static columns = {
42
+ id: { type: 'increments' },
43
+ attachment_id: { type: 'integer' },
44
+ file_path: { type: 'string', length: 500 },
45
+ width: { type: 'integer' },
46
+ height: { type: 'integer' },
47
+ format: { type: 'string', length: 20 },
48
+ size: { type: 'integer' },
49
+ source_type: { type: 'string', length: 20 },
50
+ created_at: { type: 'datetime' },
51
+ updated_at: { type: 'datetime' },
52
+ };
53
+ }
@@ -1,9 +1,35 @@
1
- import { MariaModel } from '@fuzionx/framework';
2
-
3
- export default class User extends MariaModel {
4
- static table = 'users';
5
- static connection = 'main';
6
- static softDelete = true;
7
- static hidden = ['password'];
8
- static fillable = ['name', 'email', 'password', 'role'];
1
+ import { SQLiteModel } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * User — 사용자 모델
5
+ *
6
+ * 인증 및 사용자 프로필 데이터를 관리하는 모델.
7
+ * `password` 필드는 hidden 처리되어 JSON 직렬화 시 제외.
8
+ *
9
+ * @extends SQLiteModel
10
+ */
11
+ export default class User extends SQLiteModel {
12
+ /** @type {string} 테이블 명 */
13
+ static table = 'users';
14
+
15
+ /** @type {boolean} created_at / updated_at 자동 관리 */
16
+ static timestamps = true;
17
+
18
+ /** @type {string[]} JSON 직렬화 시 제외할 필드 */
19
+ static hidden = ['password'];
20
+
21
+ /**
22
+ * 컬럼 정의
23
+ *
24
+ * @type {Object<string, import('@fuzionx/framework').ColumnDefinition>}
25
+ */
26
+ static columns = {
27
+ id: { type: 'increments' },
28
+ name: { type: 'string', length: 100 },
29
+ email: { type: 'string', length: 150, unique: true },
30
+ password: { type: 'string', length: 255 },
31
+ role: { type: 'string', length: 20, default: 'user' },
32
+ created_at: { type: 'datetime' },
33
+ updated_at: { type: 'datetime' },
34
+ };
9
35
  }
@@ -9,8 +9,8 @@
9
9
  "test": "vitest run"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/framework": "^0.1.34",
13
- "@fuzionx/client": "^0.1.34",
12
+ "@fuzionx/framework": "^0.1.37",
13
+ "@fuzionx/client": "^0.1.37",
14
14
  "joi": "^18.1.1"
15
15
  },
16
16
  "devDependencies": {
@@ -0,0 +1,110 @@
1
+ import { Task } from '@fuzionx/framework';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+
6
+ /**
7
+ * ProcessVideoThumbnailTask — 비디오 썸네일 백그라운드 처리
8
+ *
9
+ * this.worker.run()을 사용하여 CPU-intensive 작업을 별도 스레드에 위임.
10
+ * Worker 결과를 받아 메인 스레드에서 DB 등록을 수행합니다.
11
+ *
12
+ * 흐름:
13
+ * 1. Worker 스레드: 프레임 추출 + 스프라이트 시트 생성 (CPU-intensive)
14
+ * 2. 메인 스레드: DB 등록 + Post.status 업데이트 (I/O)
15
+ */
16
+ export default class ProcessVideoThumbnailTask extends Task {
17
+ static queue = 'default';
18
+ static retries = 2;
19
+ static retryDelay = 3000;
20
+ static timeout = 300000; // 5분 (대용량 영상)
21
+
22
+ async handle(data) {
23
+ const { postId, attachmentId, storagePath } = data;
24
+ const basePath = this.app.storage.basePath;
25
+
26
+ this.logger.info(`[VideoTask] Worker 시작: post=${postId}, att=${attachmentId}`);
27
+
28
+ // CPU-intensive 작업을 WorkerPool로 위임
29
+ const result = await this.worker.run('video-worker', {
30
+ postId, attachmentId, storagePath, basePath,
31
+ bridgeModulePath: this._findBridgePath(),
32
+ }, { timeout: 300000 });
33
+
34
+ // DB 등록 (메인 스레드)
35
+ const { thumbPaths, spritePath } = result;
36
+
37
+ for (const thumbAbsPath of thumbPaths) {
38
+ try {
39
+ const thumbRelPath = `uploads/thumbs/video_${attachmentId}/${path.basename(thumbAbsPath)}`;
40
+ const stat = await fs.stat(thumbAbsPath);
41
+ await this.db.Thumbnail.create({
42
+ attachment_id: attachmentId,
43
+ file_path: thumbRelPath,
44
+ width: 640,
45
+ height: 0,
46
+ format: 'jpeg',
47
+ size: stat.size,
48
+ source_type: 'video',
49
+ });
50
+ } catch (err) {
51
+ this.logger.warn(`[VideoTask] 프레임 등록 실패: ${err.message}`);
52
+ }
53
+ }
54
+
55
+ if (spritePath) {
56
+ try {
57
+ const sheetStat = await fs.stat(spritePath);
58
+ await this.db.Thumbnail.create({
59
+ attachment_id: attachmentId,
60
+ file_path: `uploads/thumbs/video_${attachmentId}/sprite.jpeg`,
61
+ width: 800,
62
+ height: 0,
63
+ format: 'jpeg',
64
+ size: sheetStat.size,
65
+ source_type: 'sprite',
66
+ });
67
+ } catch (err) {
68
+ this.logger.warn(`[VideoTask] 스프라이트 DB 등록 실패: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ // Post 상태 → published
73
+ const post = await this.db.Post.find(postId);
74
+ if (post) {
75
+ await post.update({ status: 'published' });
76
+ }
77
+ this.logger.info(`[VideoTask] 완료: post=${postId}, att=${attachmentId}, frames=${thumbPaths.length}`);
78
+ }
79
+
80
+ /**
81
+ * Bridge .node 바이너리 경로 찾기
82
+ *
83
+ * npm 패키지 resolve만 사용 — 로컬 개발 시 npm link 또는
84
+ * workspace protocol("file:../../crates/fuzionx-bridge")로 연결.
85
+ */
86
+ _findBridgePath() {
87
+ const _require = createRequire(import.meta.url);
88
+ const candidates = ['@fuzionx/bridge', 'fuzionx-bridge'];
89
+
90
+ for (const candidate of candidates) {
91
+ try {
92
+ _require.resolve(candidate);
93
+ return candidate;
94
+ } catch {}
95
+ }
96
+ throw new Error(
97
+ 'Bridge .node 바이너리를 찾을 수 없습니다. ' +
98
+ 'npm install @fuzionx/bridge 또는 package.json에 workspace 의존성을 추가하세요.'
99
+ );
100
+ }
101
+
102
+ async failed(data, error) {
103
+ const msg = error?.message || error?.toString?.() || String(error);
104
+ this.logger.error(`[VideoTask] 최종 실패: post=${data.postId}, error=${msg}`);
105
+ try {
106
+ const post = await this.db.Post.find(data.postId);
107
+ if (post) await post.update({ status: 'published' });
108
+ } catch {}
109
+ }
110
+ }
@@ -0,0 +1,84 @@
1
+ import { Task } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SampleQueuedTask — 비동기 큐 작업 샘플
5
+ *
6
+ * Task를 상속하여 비동기 위임 작업을 구현합니다.
7
+ * 기본적으로 메인 스레드에서 실행되므로
8
+ * this.db, this.service() 등 앱 컨텍스트에 접근 가능합니다.
9
+ *
10
+ * ## 디스패치 방법 (컨트롤러/서비스에서)
11
+ * app.dispatch('SampleQueuedTask', { userId: 1, email: 'user@example.com' });
12
+ *
13
+ * ## Task vs Job 차이점
14
+ * - Job: 정기 실행 (cron 스케줄, Scheduler)
15
+ * - Task: 비동기 위임 (큐, dispatch()로 호출)
16
+ *
17
+ * ## 실행 모드 (useWorker)
18
+ * - false (기본): 메인 스레드 — this.db, this.service() 접근 가능
19
+ * - true : worker_thread — CPU 전용, this.logger만 사용 가능
20
+ *
21
+ * ## CPU-intensive 작업이 필요한 경우
22
+ * handle() 내에서 직접 Worker를 생성하여 위임하고,
23
+ * Worker 결과를 받아 메인 스레드에서 DB 작업을 수행합니다.
24
+ * (ProcessVideoThumbnailTask.js 참고)
25
+ */
26
+ export default class SampleQueuedTask extends Task {
27
+ /** 큐 이름 — 동일 큐 내 순차 처리 */
28
+ static queue = 'emails';
29
+
30
+ /** 최대 재시도 횟수 */
31
+ static retries = 3;
32
+
33
+ /** 재시도 딜레이 (ms) */
34
+ static retryDelay = 5000;
35
+
36
+ /** 타임아웃 (ms) */
37
+ static timeout = 30000;
38
+
39
+ /**
40
+ * Task 실행 — 메인 스레드에서 실행 (DB 접근 가능)
41
+ *
42
+ * @param {object} data - dispatch() 시 전달된 데이터
43
+ * @param {number} data.userId - 사용자 ID
44
+ * @param {string} data.email - 이메일 주소
45
+ */
46
+ async handle(data) {
47
+ const { userId, email } = data;
48
+ this.logger.info(`[SampleTask] 환영 이메일 발송 시작: userId=${userId}, email=${email}`);
49
+
50
+ // DB에서 사용자 조회
51
+ // const user = await this.db.User.find(userId);
52
+ // if (!user) {
53
+ // this.logger.warn(`[SampleTask] 사용자 없음: ${userId}`);
54
+ // return;
55
+ // }
56
+
57
+ // 서비스를 통한 이메일 발송
58
+ // await this.service('MailService').send({
59
+ // to: email,
60
+ // subject: `환영합니다, ${user.name}님!`,
61
+ // template: 'welcome',
62
+ // data: { name: user.name },
63
+ // });
64
+
65
+ this.logger.info(`[SampleTask] 환영 이메일 발송 완료: ${email}`);
66
+ }
67
+
68
+ /**
69
+ * 최종 실패 시 호출 (모든 재시도 소진)
70
+ *
71
+ * @param {object} data - dispatch 시 전달된 데이터
72
+ * @param {Error} error - 마지막 에러
73
+ */
74
+ async failed(data, error) {
75
+ this.logger.error(`[SampleTask] 최종 실패: userId=${data.userId}, error=${error.message}`);
76
+
77
+ // 실패 기록 DB 저장
78
+ // await this.db.FailedTask.create({
79
+ // task_name: 'SampleQueuedTask',
80
+ // data: JSON.stringify(data),
81
+ // error: error.message,
82
+ // });
83
+ }
84
+ }
@@ -0,0 +1,75 @@
1
+ import { Job } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SampleScheduledJob — 정기 실행 작업 (cron 스케줄) 샘플
5
+ *
6
+ * Job을 상속하여 정기 실행 작업을 구현합니다.
7
+ * 메인 스레드에서 실행되므로 this.db, this.service() 등 접근 가능합니다.
8
+ *
9
+ * ## 간편 스케줄 표현식
10
+ * - 'every:5s' → 5초마다 (테스트용)
11
+ * - 'every:5m' → 5분마다
12
+ * - 'every:1h' → 1시간마다
13
+ * - 'daily:02:00' → 매일 새벽 2시
14
+ * - 'weekly:mon:09:00' → 매주 월요일 오전 9시
15
+ *
16
+ * ## 사용법
17
+ * 1. shared/jobs/ 에 파일 생성 (AutoLoader 자동 스캔)
18
+ * 2. handle()에 실행할 로직 구현
19
+ * 3. CPU-intensive 작업은 this.worker로 위임 (아래 예시 참고)
20
+ *
21
+ * ## Worker 위임 방식 (CPU-intensive 작업)
22
+ *
23
+ * ### 방법 1: 스크립트 파일 (shared/workers/ 폴더)
24
+ * const result = await this.worker.run('csv-parser', { path: '/tmp/data.csv' });
25
+ * await this.db.Report.create(result);
26
+ *
27
+ * ### 방법 2: 인라인 함수 (직렬화 가능한 순수 함수)
28
+ * const sum = await this.worker.exec((data) => {
29
+ * let total = 0;
30
+ * for (const item of data.items) total += item.amount;
31
+ * return total;
32
+ * }, { items });
33
+ */
34
+ export default class SampleScheduledJob extends Job {
35
+ /** 스케줄: 5초마다 실행 (테스트용, 운영 시 'daily:03:00' 등으로 변경) */
36
+ static schedule = 'daily:03:00';
37
+
38
+ /** 타임아웃: 60초 */
39
+ static timeout = 60000;
40
+
41
+ /** false로 설정하면 스케줄 비활성화 */
42
+ static enabled = false;
43
+
44
+ /**
45
+ * Job 실행 — 메인 스레드 (DB/서비스 접근 가능)
46
+ *
47
+ * CPU-intensive 작업이 필요하면 this.worker.run() 또는
48
+ * this.worker.exec()으로 별도 스레드에 위임합니다.
49
+ */
50
+ async handle() {
51
+ this.logger.info('[SampleJob] 정기 작업 시작');
52
+
53
+ // ── DB 작업 (메인 스레드에서 직접) ──
54
+ // const expiredSessions = await this.db.Session.where({ expired: true });
55
+ // this.logger.info(`[SampleJob] 만료 세션 ${expiredSessions.length}건 발견`);
56
+ // await this.db.Session.deleteMany(expiredSessions.map(s => s.id));
57
+
58
+ // ── CPU 작업 (Worker에 위임 → 결과로 DB 업데이트) ──
59
+ // const stats = await this.worker.exec((data) => {
60
+ // // 이 함수는 별도 스레드에서 실행됨
61
+ // return { processed: data.count * 2 };
62
+ // }, { count: 100 });
63
+ // await this.db.Stats.create(stats);
64
+
65
+ this.logger.info('[SampleJob] 정기 작업 완료');
66
+ }
67
+
68
+ /**
69
+ * 실패 시 호출
70
+ * @param {Error} error
71
+ */
72
+ async onError(error) {
73
+ this.logger.error('[SampleJob] 실패:', error.message);
74
+ }
75
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * video-worker.js — 비디오 처리 워커 (shared/workers/)
3
+ *
4
+ * WorkerPool.run('video-worker', data) 으로 실행됩니다.
5
+ * Bridge의 동기 N-API 호출을 별도 스레드에서 실행하여
6
+ * 메인 이벤트 루프 블로킹을 방지.
7
+ *
8
+ * workerData: { postId, attachmentId, storagePath, basePath, bridgeModulePath }
9
+ * 결과: { postId, attachmentId, thumbPaths, spritePath }
10
+ */
11
+ import { workerData, parentPort, isMainThread } from 'node:worker_threads';
12
+
13
+ // 메인 스레드에서 import 되면 아무것도 하지 않음 (앱 auto-load 방지)
14
+ if (isMainThread) process.exitCode = 0;
15
+
16
+ if (!isMainThread) {
17
+ const { promises: fs } = await import('node:fs');
18
+ const path = await import('node:path');
19
+ const { createRequire } = await import('node:module');
20
+
21
+ const { postId, attachmentId, storagePath, basePath, bridgeModulePath } = workerData;
22
+
23
+ try {
24
+ // Bridge .node 바이너리 로드
25
+ const require = createRequire(import.meta.url);
26
+ const bridge = require(bridgeModulePath);
27
+
28
+ const fullPath = path.join(basePath, storagePath);
29
+ const thumbDir = path.join(basePath, `uploads/thumbs/video_${attachmentId}`);
30
+ await fs.mkdir(thumbDir, { recursive: true });
31
+
32
+ const THUMB_COUNT = 25;
33
+ const SPRITE_COLS = 5;
34
+ let duration = 60;
35
+
36
+ // 1. 영상 길이
37
+ try {
38
+ const info = bridge.mediaVideoInfo(fullPath);
39
+ if (info?.duration) duration = info.duration;
40
+ } catch (e) {
41
+ // videoInfo 실패 시 기본 60s 사용
42
+ }
43
+
44
+ // 2. 프레임 추출
45
+ const interval = Math.max(1, Math.floor(duration / (THUMB_COUNT + 1)));
46
+ const generatedPaths = bridge.mediaVideoThumbnails(fullPath, thumbDir, interval, 640, 'jpeg') || [];
47
+ const filesToRegister = generatedPaths.slice(0, THUMB_COUNT);
48
+
49
+ // 3. 스프라이트 시트
50
+ let spritePath = null;
51
+ if (filesToRegister.length > 0) {
52
+ try {
53
+ spritePath = path.join(thumbDir, 'sprite.jpeg');
54
+ bridge.mediaVideoPreviewSheet(filesToRegister, spritePath, SPRITE_COLS, 160, 0);
55
+ } catch (e) {
56
+ spritePath = null;
57
+ }
58
+ }
59
+
60
+ // WorkerPool.run()이 resolve할 최종 결과
61
+ parentPort.postMessage({
62
+ postId, attachmentId,
63
+ thumbPaths: filesToRegister,
64
+ spritePath,
65
+ });
66
+ } catch (e) {
67
+ throw e; // WorkerPool이 error 이벤트로 reject
68
+ }
69
+ }
@@ -4,7 +4,7 @@
4
4
  "description": "Vue.js 3 SPA + Tera SSR 하이브리드. WASM 암호화 통신.",
5
5
  "features": ["auth", "board", "i18n", "asp", "wasm"],
6
6
  "dependencies": {
7
- "@fuzionx/client": "^0.1.34"
7
+ "@fuzionx/client": "^0.1.37"
8
8
  },
9
9
  "devDependencies": {},
10
10
  "spaDevDependencies": {