@mandujs/core 0.12.1 → 0.12.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.
- package/package.json +8 -8
- package/src/guard/healing.ts +2 -0
- package/src/guard/index.ts +2 -0
- package/src/guard/negotiation.ts +430 -4
- package/src/guard/presets/cqrs.test.ts +154 -0
- package/src/guard/presets/cqrs.ts +107 -0
- package/src/guard/presets/index.ts +3 -0
- package/src/guard/suggestions.ts +6 -0
- package/src/guard/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"src/**/*"
|
|
15
15
|
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
18
|
+
"test:hydration": "bun test tests/hydration",
|
|
19
|
+
"test:streaming": "bun test tests/streaming-ssr",
|
|
20
|
+
"test:watch": "bun test --watch"
|
|
21
|
+
},
|
|
16
22
|
"devDependencies": {
|
|
17
23
|
"@happy-dom/global-registrator": "^15.0.0"
|
|
18
24
|
},
|
|
@@ -47,11 +53,5 @@
|
|
|
47
53
|
"glob": "^13.0.0",
|
|
48
54
|
"minimatch": "^10.1.1",
|
|
49
55
|
"ollama": "^0.6.3"
|
|
50
|
-
},
|
|
51
|
-
"scripts": {
|
|
52
|
-
"test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
|
|
53
|
-
"test:hydration": "bun test tests/hydration",
|
|
54
|
-
"test:streaming": "bun test tests/streaming-ssr",
|
|
55
|
-
"test:watch": "bun test --watch"
|
|
56
56
|
}
|
|
57
|
-
}
|
|
57
|
+
}
|
package/src/guard/healing.ts
CHANGED
|
@@ -669,6 +669,8 @@ function getLayerHierarchy(preset?: GuardPreset): string {
|
|
|
669
669
|
return "adapters → ports → domain";
|
|
670
670
|
case "atomic":
|
|
671
671
|
return "pages → templates → organisms → molecules → atoms";
|
|
672
|
+
case "cqrs":
|
|
673
|
+
return "api → commands|queries → dto/events → domain → shared";
|
|
672
674
|
case "mandu":
|
|
673
675
|
return "client(FSD) | shared | server(Clean)";
|
|
674
676
|
default:
|
package/src/guard/index.ts
CHANGED
|
@@ -139,11 +139,13 @@ export {
|
|
|
139
139
|
cleanPreset,
|
|
140
140
|
hexagonalPreset,
|
|
141
141
|
atomicPreset,
|
|
142
|
+
cqrsPreset,
|
|
142
143
|
manduPreset,
|
|
143
144
|
FSD_HIERARCHY,
|
|
144
145
|
CLEAN_HIERARCHY,
|
|
145
146
|
HEXAGONAL_HIERARCHY,
|
|
146
147
|
ATOMIC_HIERARCHY,
|
|
148
|
+
CQRS_HIERARCHY,
|
|
147
149
|
} from "./presets";
|
|
148
150
|
|
|
149
151
|
// ═══════════════════════════════════════════════════════════════════════════
|
package/src/guard/negotiation.ts
CHANGED
|
@@ -118,7 +118,11 @@ export type FileTemplate =
|
|
|
118
118
|
| "util"
|
|
119
119
|
| "type"
|
|
120
120
|
| "test"
|
|
121
|
-
| "slot"
|
|
121
|
+
| "slot"
|
|
122
|
+
| "command"
|
|
123
|
+
| "query"
|
|
124
|
+
| "event"
|
|
125
|
+
| "dto";
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* 협상 응답
|
|
@@ -438,6 +442,341 @@ const STRUCTURE_TEMPLATES: Record<FeatureCategory, (featureName: string) => Dire
|
|
|
438
442
|
],
|
|
439
443
|
};
|
|
440
444
|
|
|
445
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
446
|
+
// CQRS Structure Templates
|
|
447
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* CQRS 프리셋 전용 구조 템플릿
|
|
451
|
+
*
|
|
452
|
+
* application 레이어를 commands/queries/dto/events/mappers로 세분화
|
|
453
|
+
*/
|
|
454
|
+
const CQRS_STRUCTURE_TEMPLATES: Record<FeatureCategory, (featureName: string) => DirectoryProposal[]> = {
|
|
455
|
+
auth: (name) => [
|
|
456
|
+
{
|
|
457
|
+
path: `src/domain/${name}`,
|
|
458
|
+
purpose: `${name} 도메인 모델`,
|
|
459
|
+
layer: "domain",
|
|
460
|
+
files: [
|
|
461
|
+
{ name: `${name}.entity.ts`, purpose: "사용자/인증 엔티티", template: "type" },
|
|
462
|
+
{ name: `${name}.service.ts`, purpose: "도메인 서비스 인터페이스", template: "service" },
|
|
463
|
+
{ name: `${name}.repository.ts`, purpose: "Repository 인터페이스", template: "repository" },
|
|
464
|
+
],
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
path: `src/application/commands/${name}`,
|
|
468
|
+
purpose: `${name} 쓰기 경로`,
|
|
469
|
+
layer: "application/commands",
|
|
470
|
+
files: [
|
|
471
|
+
{ name: `login.command.ts`, purpose: "로그인 커맨드 핸들러", template: "command" },
|
|
472
|
+
{ name: `logout.command.ts`, purpose: "로그아웃 커맨드 핸들러", template: "command" },
|
|
473
|
+
{ name: `refresh-token.command.ts`, purpose: "토큰 갱신 커맨드 핸들러", template: "command" },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
path: `src/application/queries/${name}`,
|
|
478
|
+
purpose: `${name} 읽기 경로`,
|
|
479
|
+
layer: "application/queries",
|
|
480
|
+
files: [
|
|
481
|
+
{ name: `get-session.query.ts`, purpose: "세션 조회 쿼리 핸들러", template: "query" },
|
|
482
|
+
{ name: `verify-token.query.ts`, purpose: "토큰 검증 쿼리 핸들러", template: "query" },
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
path: `src/application/dto/${name}`,
|
|
487
|
+
purpose: `${name} DTO`,
|
|
488
|
+
layer: "application/dto",
|
|
489
|
+
files: [
|
|
490
|
+
{ name: `login.dto.ts`, purpose: "로그인 요청/응답 DTO", template: "dto" },
|
|
491
|
+
{ name: `token.dto.ts`, purpose: "토큰 DTO", template: "dto" },
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
path: `src/application/events/${name}`,
|
|
496
|
+
purpose: `${name} 도메인 이벤트`,
|
|
497
|
+
layer: "application/events",
|
|
498
|
+
files: [
|
|
499
|
+
{ name: `user-logged-in.event.ts`, purpose: "로그인 성공 이벤트", template: "event" },
|
|
500
|
+
{ name: `user-logged-out.event.ts`, purpose: "로그아웃 이벤트", template: "event" },
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
path: `src/infra/${name}`,
|
|
505
|
+
purpose: `${name} 인프라 어댑터`,
|
|
506
|
+
layer: "infrastructure",
|
|
507
|
+
files: [
|
|
508
|
+
{ name: `token.provider.ts`, purpose: "토큰 생성/검증 구현", template: "service" },
|
|
509
|
+
{ name: `session.repository.ts`, purpose: "세션 저장소 구현", template: "repository" },
|
|
510
|
+
],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
path: `src/api/${name}`,
|
|
514
|
+
purpose: `${name} API 라우트`,
|
|
515
|
+
layer: "api",
|
|
516
|
+
files: [
|
|
517
|
+
{ name: `login/route.ts`, purpose: "로그인 API → LoginCommand 디스패치", template: "route", isSlot: true },
|
|
518
|
+
{ name: `logout/route.ts`, purpose: "로그아웃 API → LogoutCommand 디스패치", template: "route", isSlot: true },
|
|
519
|
+
{ name: `refresh/route.ts`, purpose: "토큰 갱신 API → RefreshTokenCommand 디스패치", template: "route", isSlot: true },
|
|
520
|
+
{ name: `session/route.ts`, purpose: "세션 조회 API → GetSessionQuery 디스패치", template: "route", isSlot: true },
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
|
|
525
|
+
crud: (name) => [
|
|
526
|
+
{
|
|
527
|
+
path: `src/domain/${name}`,
|
|
528
|
+
purpose: `${name} 도메인`,
|
|
529
|
+
layer: "domain",
|
|
530
|
+
files: [
|
|
531
|
+
{ name: `${name}.entity.ts`, purpose: "엔티티 정의", template: "type" },
|
|
532
|
+
{ name: `${name}.repository.ts`, purpose: "Repository 인터페이스", template: "repository" },
|
|
533
|
+
],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
path: `src/application/commands/${name}`,
|
|
537
|
+
purpose: `${name} 쓰기 커맨드`,
|
|
538
|
+
layer: "application/commands",
|
|
539
|
+
files: [
|
|
540
|
+
{ name: `create-${name}.command.ts`, purpose: "생성 커맨드 핸들러", template: "command" },
|
|
541
|
+
{ name: `update-${name}.command.ts`, purpose: "수정 커맨드 핸들러", template: "command" },
|
|
542
|
+
{ name: `delete-${name}.command.ts`, purpose: "삭제 커맨드 핸들러", template: "command" },
|
|
543
|
+
],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
path: `src/application/queries/${name}`,
|
|
547
|
+
purpose: `${name} 읽기 쿼리`,
|
|
548
|
+
layer: "application/queries",
|
|
549
|
+
files: [
|
|
550
|
+
{ name: `get-${name}.query.ts`, purpose: "단건 조회 쿼리 핸들러", template: "query" },
|
|
551
|
+
{ name: `list-${name}.query.ts`, purpose: "목록 조회 쿼리 핸들러", template: "query" },
|
|
552
|
+
],
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
path: `src/application/dto/${name}`,
|
|
556
|
+
purpose: `${name} DTO`,
|
|
557
|
+
layer: "application/dto",
|
|
558
|
+
files: [
|
|
559
|
+
{ name: `create-${name}.dto.ts`, purpose: "생성 요청 DTO", template: "dto" },
|
|
560
|
+
{ name: `update-${name}.dto.ts`, purpose: "수정 요청 DTO", template: "dto" },
|
|
561
|
+
{ name: `${name}-response.dto.ts`, purpose: "응답 DTO", template: "dto" },
|
|
562
|
+
],
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
path: `src/infra/${name}`,
|
|
566
|
+
purpose: `${name} Repository 구현`,
|
|
567
|
+
layer: "infrastructure",
|
|
568
|
+
files: [
|
|
569
|
+
{ name: `${name}.repository-impl.ts`, purpose: "Repository 구현체", template: "repository" },
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
path: `src/api/${name}`,
|
|
574
|
+
purpose: `${name} API`,
|
|
575
|
+
layer: "api",
|
|
576
|
+
files: [
|
|
577
|
+
{ name: `route.ts`, purpose: "목록/생성 API (GET→ListQuery, POST→CreateCommand)", template: "route", isSlot: true },
|
|
578
|
+
{ name: `[id]/route.ts`, purpose: "상세/수정/삭제 API (GET→GetQuery, PUT→UpdateCommand, DELETE→DeleteCommand)", template: "route", isSlot: true },
|
|
579
|
+
],
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
|
|
583
|
+
api: (name) => [
|
|
584
|
+
{
|
|
585
|
+
path: `src/domain/${name}`,
|
|
586
|
+
purpose: `${name} 도메인`,
|
|
587
|
+
layer: "domain",
|
|
588
|
+
files: [
|
|
589
|
+
{ name: `${name}.service.ts`, purpose: "도메인 서비스", template: "service" },
|
|
590
|
+
{ name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
path: `src/application/commands/${name}`,
|
|
595
|
+
purpose: `${name} 커맨드`,
|
|
596
|
+
layer: "application/commands",
|
|
597
|
+
files: [
|
|
598
|
+
{ name: `${name}.command.ts`, purpose: "커맨드 핸들러", template: "command" },
|
|
599
|
+
],
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
path: `src/application/queries/${name}`,
|
|
603
|
+
purpose: `${name} 쿼리`,
|
|
604
|
+
layer: "application/queries",
|
|
605
|
+
files: [
|
|
606
|
+
{ name: `${name}.query.ts`, purpose: "쿼리 핸들러", template: "query" },
|
|
607
|
+
],
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
path: `src/api/${name}`,
|
|
611
|
+
purpose: `${name} API 엔드포인트`,
|
|
612
|
+
layer: "api",
|
|
613
|
+
files: [
|
|
614
|
+
{ name: `route.ts`, purpose: "API 핸들러 → Command/Query 디스패치", template: "route", isSlot: true },
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
|
|
619
|
+
ui: (name) => [
|
|
620
|
+
{
|
|
621
|
+
path: `src/application/queries/${name}`,
|
|
622
|
+
purpose: `${name} 데이터 조회`,
|
|
623
|
+
layer: "application/queries",
|
|
624
|
+
files: [
|
|
625
|
+
{ name: `get-${name}.query.ts`, purpose: "UI용 데이터 조회 쿼리", template: "query" },
|
|
626
|
+
],
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
path: `src/application/dto/${name}`,
|
|
630
|
+
purpose: `${name} DTO`,
|
|
631
|
+
layer: "application/dto",
|
|
632
|
+
files: [
|
|
633
|
+
{ name: `${name}-view.dto.ts`, purpose: "뷰 모델 DTO", template: "dto" },
|
|
634
|
+
],
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
path: `src/api/${name}`,
|
|
638
|
+
purpose: `${name} API`,
|
|
639
|
+
layer: "api",
|
|
640
|
+
files: [
|
|
641
|
+
{ name: `route.ts`, purpose: "UI 데이터 API", template: "route", isSlot: true },
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
|
|
646
|
+
integration: (name) => [
|
|
647
|
+
{
|
|
648
|
+
path: `src/domain/${name}`,
|
|
649
|
+
purpose: `${name} 도메인 포트`,
|
|
650
|
+
layer: "domain",
|
|
651
|
+
files: [
|
|
652
|
+
{ name: `${name}.port.ts`, purpose: "포트 인터페이스", template: "type" },
|
|
653
|
+
],
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
path: `src/application/commands/${name}`,
|
|
657
|
+
purpose: `${name} 동기화 커맨드`,
|
|
658
|
+
layer: "application/commands",
|
|
659
|
+
files: [
|
|
660
|
+
{ name: `sync-${name}.command.ts`, purpose: "외부 서비스 동기화 커맨드", template: "command" },
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
path: `src/application/events/${name}`,
|
|
665
|
+
purpose: `${name} 연동 이벤트`,
|
|
666
|
+
layer: "application/events",
|
|
667
|
+
files: [
|
|
668
|
+
{ name: `${name}-synced.event.ts`, purpose: "동기화 완료 이벤트", template: "event" },
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
path: `src/infra/${name}`,
|
|
673
|
+
purpose: `${name} 외부 서비스 어댑터`,
|
|
674
|
+
layer: "infrastructure",
|
|
675
|
+
files: [
|
|
676
|
+
{ name: `${name}.client.ts`, purpose: "외부 API 클라이언트", template: "service" },
|
|
677
|
+
{ name: `${name}.config.ts`, purpose: "연동 설정", template: "util" },
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
path: `src/api/webhooks/${name}`,
|
|
682
|
+
purpose: `${name} 웹훅`,
|
|
683
|
+
layer: "api",
|
|
684
|
+
files: [
|
|
685
|
+
{ name: `route.ts`, purpose: "웹훅 핸들러", template: "route", isSlot: true },
|
|
686
|
+
],
|
|
687
|
+
},
|
|
688
|
+
],
|
|
689
|
+
|
|
690
|
+
data: (name) => [
|
|
691
|
+
{
|
|
692
|
+
path: `src/domain/${name}`,
|
|
693
|
+
purpose: `${name} 데이터 처리 도메인`,
|
|
694
|
+
layer: "domain",
|
|
695
|
+
files: [
|
|
696
|
+
{ name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
|
|
697
|
+
],
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
path: `src/application/commands/${name}`,
|
|
701
|
+
purpose: `${name} 데이터 처리 커맨드`,
|
|
702
|
+
layer: "application/commands",
|
|
703
|
+
files: [
|
|
704
|
+
{ name: `import-${name}.command.ts`, purpose: "데이터 임포트 커맨드", template: "command" },
|
|
705
|
+
{ name: `transform-${name}.command.ts`, purpose: "데이터 변환 커맨드", template: "command" },
|
|
706
|
+
],
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
path: `src/application/queries/${name}`,
|
|
710
|
+
purpose: `${name} 데이터 조회`,
|
|
711
|
+
layer: "application/queries",
|
|
712
|
+
files: [
|
|
713
|
+
{ name: `export-${name}.query.ts`, purpose: "데이터 익스포트 쿼리", template: "query" },
|
|
714
|
+
],
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
path: `src/application/dto/${name}`,
|
|
718
|
+
purpose: `${name} DTO`,
|
|
719
|
+
layer: "application/dto",
|
|
720
|
+
files: [
|
|
721
|
+
{ name: `${name}-import.dto.ts`, purpose: "임포트 DTO", template: "dto" },
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
|
|
726
|
+
util: (name) => [
|
|
727
|
+
{
|
|
728
|
+
path: `src/shared/${name}`,
|
|
729
|
+
purpose: `${name} 유틸리티`,
|
|
730
|
+
layer: "shared",
|
|
731
|
+
files: [
|
|
732
|
+
{ name: `${name}.ts`, purpose: "유틸리티 함수", template: "util" },
|
|
733
|
+
{ name: `${name}.test.ts`, purpose: "테스트", template: "test" },
|
|
734
|
+
{ name: `index.ts`, purpose: "Public API", template: "util" },
|
|
735
|
+
],
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
|
|
739
|
+
config: (name) => [
|
|
740
|
+
{
|
|
741
|
+
path: `src/shared/config`,
|
|
742
|
+
purpose: "설정 관리",
|
|
743
|
+
layer: "shared",
|
|
744
|
+
files: [
|
|
745
|
+
{ name: `${name}.config.ts`, purpose: `${name} 설정`, template: "util" },
|
|
746
|
+
{ name: `${name}.schema.ts`, purpose: "설정 스키마 (Zod)", template: "type" },
|
|
747
|
+
],
|
|
748
|
+
},
|
|
749
|
+
],
|
|
750
|
+
|
|
751
|
+
other: (name) => [
|
|
752
|
+
{
|
|
753
|
+
path: `src/domain/${name}`,
|
|
754
|
+
purpose: `${name} 도메인`,
|
|
755
|
+
layer: "domain",
|
|
756
|
+
files: [
|
|
757
|
+
{ name: `${name}.service.ts`, purpose: "도메인 서비스", template: "service" },
|
|
758
|
+
{ name: `${name}.types.ts`, purpose: "타입 정의", template: "type" },
|
|
759
|
+
],
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
path: `src/application/commands/${name}`,
|
|
763
|
+
purpose: `${name} 커맨드`,
|
|
764
|
+
layer: "application/commands",
|
|
765
|
+
files: [
|
|
766
|
+
{ name: `${name}.command.ts`, purpose: "커맨드 핸들러", template: "command" },
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
path: `src/application/queries/${name}`,
|
|
771
|
+
purpose: `${name} 쿼리`,
|
|
772
|
+
layer: "application/queries",
|
|
773
|
+
files: [
|
|
774
|
+
{ name: `${name}.query.ts`, purpose: "쿼리 핸들러", template: "query" },
|
|
775
|
+
],
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
};
|
|
779
|
+
|
|
441
780
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
442
781
|
// File Templates
|
|
443
782
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -557,6 +896,91 @@ describe("${name}", () => {
|
|
|
557
896
|
expect(true).toBe(true);
|
|
558
897
|
});
|
|
559
898
|
});
|
|
899
|
+
`;
|
|
900
|
+
|
|
901
|
+
case "command":
|
|
902
|
+
return `/**
|
|
903
|
+
* ${purpose}
|
|
904
|
+
*
|
|
905
|
+
* Command Handler - 쓰기 경로
|
|
906
|
+
*/
|
|
907
|
+
|
|
908
|
+
export interface ${toPascalCase(name)}Command {
|
|
909
|
+
// TODO: Define command payload
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export interface ${toPascalCase(name)}Result {
|
|
913
|
+
// TODO: Define command result
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export class ${toPascalCase(name)}Handler {
|
|
917
|
+
async execute(command: ${toPascalCase(name)}Command): Promise<${toPascalCase(name)}Result> {
|
|
918
|
+
// TODO: Implement command handler
|
|
919
|
+
throw new Error("Not implemented");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
`;
|
|
923
|
+
|
|
924
|
+
case "query":
|
|
925
|
+
return `/**
|
|
926
|
+
* ${purpose}
|
|
927
|
+
*
|
|
928
|
+
* Query Handler - 읽기 경로
|
|
929
|
+
*/
|
|
930
|
+
|
|
931
|
+
export interface ${toPascalCase(name)}Query {
|
|
932
|
+
// TODO: Define query parameters
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export interface ${toPascalCase(name)}Result {
|
|
936
|
+
// TODO: Define query result
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
export class ${toPascalCase(name)}Handler {
|
|
940
|
+
async execute(query: ${toPascalCase(name)}Query): Promise<${toPascalCase(name)}Result> {
|
|
941
|
+
// TODO: Implement query handler
|
|
942
|
+
throw new Error("Not implemented");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
`;
|
|
946
|
+
|
|
947
|
+
case "event":
|
|
948
|
+
return `/**
|
|
949
|
+
* ${purpose}
|
|
950
|
+
*
|
|
951
|
+
* Domain Event
|
|
952
|
+
*/
|
|
953
|
+
|
|
954
|
+
export interface ${toPascalCase(name)}Event {
|
|
955
|
+
readonly type: "${name}";
|
|
956
|
+
readonly occurredAt: Date;
|
|
957
|
+
// TODO: Define event payload
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function create${toPascalCase(name)}Event(
|
|
961
|
+
// TODO: Define factory parameters
|
|
962
|
+
): ${toPascalCase(name)}Event {
|
|
963
|
+
return {
|
|
964
|
+
type: "${name}",
|
|
965
|
+
occurredAt: new Date(),
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
`;
|
|
969
|
+
|
|
970
|
+
case "dto":
|
|
971
|
+
return `/**
|
|
972
|
+
* ${purpose}
|
|
973
|
+
*
|
|
974
|
+
* Data Transfer Object
|
|
975
|
+
*/
|
|
976
|
+
|
|
977
|
+
export interface ${toPascalCase(name)}Dto {
|
|
978
|
+
// TODO: Define DTO fields
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export interface ${toPascalCase(name)}ResponseDto {
|
|
982
|
+
// TODO: Define response DTO fields
|
|
983
|
+
}
|
|
560
984
|
`;
|
|
561
985
|
|
|
562
986
|
case "util":
|
|
@@ -659,6 +1083,7 @@ function adjustStructureForPreset(
|
|
|
659
1083
|
"shared": "src/utils",
|
|
660
1084
|
"app/api": "src/api",
|
|
661
1085
|
},
|
|
1086
|
+
cqrs: {}, // CQRS 전용 템플릿이 자체 경로 사용
|
|
662
1087
|
mandu: {}, // 기본값, 매핑 불필요
|
|
663
1088
|
};
|
|
664
1089
|
|
|
@@ -716,11 +1141,12 @@ export async function negotiate(
|
|
|
716
1141
|
|
|
717
1142
|
// 4. 프리셋 정의 로드 및 구조 템플릿 선택
|
|
718
1143
|
const presetDef = getPreset(preset);
|
|
719
|
-
const
|
|
1144
|
+
const templates = preset === "cqrs" ? CQRS_STRUCTURE_TEMPLATES : STRUCTURE_TEMPLATES;
|
|
1145
|
+
const templateFn = templates[category] || templates.other;
|
|
720
1146
|
let structure = templateFn(featureName);
|
|
721
1147
|
|
|
722
|
-
// 5. 프리셋에 따른 구조 조정
|
|
723
|
-
if (presetDef && preset !== "mandu") {
|
|
1148
|
+
// 5. 프리셋에 따른 구조 조정 (cqrs, mandu는 자체 경로 사용)
|
|
1149
|
+
if (presetDef && preset !== "mandu" && preset !== "cqrs") {
|
|
724
1150
|
structure = adjustStructureForPreset(structure, presetDef, preset);
|
|
725
1151
|
}
|
|
726
1152
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CQRS Preset Tests
|
|
3
|
+
*
|
|
4
|
+
* CQRS 프리셋의 구조 검증 및 Command/Query 분리 규칙 테스트
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
8
|
+
import { mkdtemp, rm, mkdir } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
|
|
12
|
+
import { presets, getPreset } from "./index";
|
|
13
|
+
import { negotiate, generateScaffold } from "../negotiation";
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// Preset Structure Tests
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
describe("CQRS Preset - Structure", () => {
|
|
20
|
+
it("should be registered in presets map", () => {
|
|
21
|
+
expect(presets.cqrs).toBeDefined();
|
|
22
|
+
expect(presets.cqrs).toBe(cqrsPreset);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should be retrievable via getPreset", () => {
|
|
26
|
+
expect(getPreset("cqrs")).toBe(cqrsPreset);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should have correct name and description", () => {
|
|
30
|
+
expect(cqrsPreset.name).toBe("cqrs");
|
|
31
|
+
expect(cqrsPreset.description).toContain("CQRS");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should define all 10 layers", () => {
|
|
35
|
+
expect(cqrsPreset.layers).toHaveLength(10);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should have hierarchy matching layer names", () => {
|
|
39
|
+
const layerNames = cqrsPreset.layers.map((l) => l.name);
|
|
40
|
+
for (const h of CQRS_HIERARCHY) {
|
|
41
|
+
expect(layerNames).toContain(h);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should set layerViolation severity to error", () => {
|
|
46
|
+
expect(cqrsPreset.defaultSeverity?.layerViolation).toBe("error");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
51
|
+
// CQRS Separation Rules - TODO(human)
|
|
52
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
describe("CQRS Preset - Command/Query Separation", () => {
|
|
55
|
+
// TODO(human): CQRS 분리 규칙 검증 테스트를 작성하세요.
|
|
56
|
+
//
|
|
57
|
+
// 힌트:
|
|
58
|
+
// - cqrsPreset.layers 배열에서 특정 레이어를 찾으려면:
|
|
59
|
+
// const commandsLayer = cqrsPreset.layers.find(l => l.name === "application/commands");
|
|
60
|
+
// - commandsLayer.canImport 배열에 특정 레이어가 포함/미포함인지 검증
|
|
61
|
+
//
|
|
62
|
+
// 작성해야 할 테스트 케이스:
|
|
63
|
+
// 1. commands는 queries를 import할 수 없어야 함
|
|
64
|
+
// 2. queries는 commands와 events를 import할 수 없어야 함
|
|
65
|
+
// 3. commands는 domain, dto, events를 import할 수 있어야 함
|
|
66
|
+
// 4. queries는 domain, dto를 import할 수 있어야 함
|
|
67
|
+
// 5. domain은 shared만 import할 수 있어야 함
|
|
68
|
+
// 6. shared는 아무것도 import할 수 없어야 함
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
// CQRS Scaffolding Tests
|
|
73
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
74
|
+
|
|
75
|
+
let SCAFFOLD_DIR: string;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
SCAFFOLD_DIR = await mkdtemp(join(tmpdir(), "test-cqrs-scaffold-"));
|
|
79
|
+
await mkdir(join(SCAFFOLD_DIR, "spec", "decisions"), { recursive: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await rm(SCAFFOLD_DIR, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("CQRS Preset - Scaffold Templates", () => {
|
|
87
|
+
it("should generate commands/queries separation for auth", async () => {
|
|
88
|
+
const result = await negotiate(
|
|
89
|
+
{ intent: "사용자 인증 추가", category: "auth", preset: "cqrs" },
|
|
90
|
+
SCAFFOLD_DIR,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(result.approved).toBe(true);
|
|
94
|
+
expect(result.preset).toBe("cqrs");
|
|
95
|
+
|
|
96
|
+
const paths = result.structure.map((s) => s.path);
|
|
97
|
+
expect(paths.some((p) => p.includes("application/commands"))).toBe(true);
|
|
98
|
+
expect(paths.some((p) => p.includes("application/queries"))).toBe(true);
|
|
99
|
+
expect(paths.some((p) => p.includes("application/dto"))).toBe(true);
|
|
100
|
+
expect(paths.some((p) => p.includes("application/events"))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should generate CRUD with separate commands and queries", async () => {
|
|
104
|
+
const result = await negotiate(
|
|
105
|
+
{ intent: "상품 관리 CRUD", category: "crud", preset: "cqrs" },
|
|
106
|
+
SCAFFOLD_DIR,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const commandsDir = result.structure.find((s) => s.path.includes("application/commands"));
|
|
110
|
+
const queriesDir = result.structure.find((s) => s.path.includes("application/queries"));
|
|
111
|
+
|
|
112
|
+
expect(commandsDir).toBeDefined();
|
|
113
|
+
expect(queriesDir).toBeDefined();
|
|
114
|
+
|
|
115
|
+
// commands에 create/update/delete가 있어야 함
|
|
116
|
+
const cmdFiles = commandsDir!.files.map((f) => f.name);
|
|
117
|
+
expect(cmdFiles.some((f) => f.includes("create"))).toBe(true);
|
|
118
|
+
expect(cmdFiles.some((f) => f.includes("update"))).toBe(true);
|
|
119
|
+
expect(cmdFiles.some((f) => f.includes("delete"))).toBe(true);
|
|
120
|
+
|
|
121
|
+
// queries에 get/list가 있어야 함
|
|
122
|
+
const qryFiles = queriesDir!.files.map((f) => f.name);
|
|
123
|
+
expect(qryFiles.some((f) => f.includes("get"))).toBe(true);
|
|
124
|
+
expect(qryFiles.some((f) => f.includes("list"))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should create actual scaffold files", async () => {
|
|
128
|
+
const testDir = await mkdtemp(join(SCAFFOLD_DIR, "actual-"));
|
|
129
|
+
await mkdir(join(testDir, "spec", "decisions"), { recursive: true });
|
|
130
|
+
|
|
131
|
+
const plan = await negotiate(
|
|
132
|
+
{ intent: "order feature", category: "crud", preset: "cqrs" },
|
|
133
|
+
testDir,
|
|
134
|
+
);
|
|
135
|
+
const result = await generateScaffold(plan.structure, testDir);
|
|
136
|
+
|
|
137
|
+
expect(result.success).toBe(true);
|
|
138
|
+
expect(result.createdFiles.some((f) => f.includes("command"))).toBe(true);
|
|
139
|
+
expect(result.createdFiles.some((f) => f.includes("query"))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should use src/ prefixed paths (no adjustStructureForPreset)", async () => {
|
|
143
|
+
const result = await negotiate(
|
|
144
|
+
{ intent: "payment api", category: "api", preset: "cqrs" },
|
|
145
|
+
SCAFFOLD_DIR,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// CQRS 전용 템플릿은 이미 src/ 기준 경로를 사용
|
|
149
|
+
const paths = result.structure.map((s) => s.path);
|
|
150
|
+
for (const p of paths) {
|
|
151
|
+
expect(p.startsWith("src/")).toBe(true);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CQRS Preset
|
|
3
|
+
*
|
|
4
|
+
* Command Query Responsibility Segregation 아키텍처
|
|
5
|
+
* 쓰기 경로(Commands)와 읽기 경로(Queries)의 격리를 프레임워크 레벨에서 강제
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PresetDefinition } from "../types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CQRS 레이어 계층 구조
|
|
12
|
+
*
|
|
13
|
+
* 의존성: 외부 → 내부 (domain이 핵심)
|
|
14
|
+
* commands와 queries는 서로 격리됨
|
|
15
|
+
*/
|
|
16
|
+
export const CQRS_HIERARCHY = [
|
|
17
|
+
"api",
|
|
18
|
+
"infra",
|
|
19
|
+
"application/commands",
|
|
20
|
+
"application/queries",
|
|
21
|
+
"application/dto",
|
|
22
|
+
"application/mappers",
|
|
23
|
+
"application/events",
|
|
24
|
+
"domain",
|
|
25
|
+
"core",
|
|
26
|
+
"shared",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CQRS 프리셋 정의
|
|
31
|
+
*/
|
|
32
|
+
export const cqrsPreset: PresetDefinition = {
|
|
33
|
+
name: "cqrs",
|
|
34
|
+
description: "CQRS - Command/Query 분리 아키텍처",
|
|
35
|
+
|
|
36
|
+
hierarchy: [...CQRS_HIERARCHY],
|
|
37
|
+
|
|
38
|
+
layers: [
|
|
39
|
+
{
|
|
40
|
+
name: "api",
|
|
41
|
+
pattern: "src/**/api/**",
|
|
42
|
+
canImport: ["application/commands", "application/queries", "application/dto", "core", "shared"],
|
|
43
|
+
description: "Controllers, Routes - Command/Query 디스패치",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "infra",
|
|
47
|
+
pattern: "src/**/infra/**",
|
|
48
|
+
canImport: ["application/commands", "application/queries", "domain", "core", "shared"],
|
|
49
|
+
description: "Repository 구현, 외부 서비스 어댑터",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "application/commands",
|
|
53
|
+
pattern: "src/**/application/commands/**",
|
|
54
|
+
canImport: ["domain", "application/dto", "application/events", "core", "shared"],
|
|
55
|
+
description: "Command Handlers - 쓰기 경로 (queries 접근 불가)",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "application/queries",
|
|
59
|
+
pattern: "src/**/application/queries/**",
|
|
60
|
+
canImport: ["domain", "application/dto", "core", "shared"],
|
|
61
|
+
description: "Query Handlers - 읽기 경로 (commands, events 접근 불가)",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "application/dto",
|
|
65
|
+
pattern: "src/**/application/dto/**",
|
|
66
|
+
canImport: ["domain", "shared"],
|
|
67
|
+
description: "Data Transfer Objects",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "application/mappers",
|
|
71
|
+
pattern: "src/**/application/mappers/**",
|
|
72
|
+
canImport: ["domain", "application/dto", "shared"],
|
|
73
|
+
description: "Domain ↔ DTO 변환기",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "application/events",
|
|
77
|
+
pattern: "src/**/application/events/**",
|
|
78
|
+
canImport: ["domain", "shared"],
|
|
79
|
+
description: "Domain Events, Integration Events",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "domain",
|
|
83
|
+
pattern: "src/**/domain/**",
|
|
84
|
+
canImport: ["shared"],
|
|
85
|
+
description: "Entities, Value Objects, Domain Services, Aggregates",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "core",
|
|
89
|
+
pattern: "src/core/**",
|
|
90
|
+
canImport: ["shared"],
|
|
91
|
+
description: "공통 핵심 (auth, config, errors, CQRS bus)",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "shared",
|
|
95
|
+
pattern: "src/shared/**",
|
|
96
|
+
canImport: [],
|
|
97
|
+
description: "공유 유틸리티, 타입, 인터페이스",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
|
|
101
|
+
defaultSeverity: {
|
|
102
|
+
layerViolation: "error",
|
|
103
|
+
circularDependency: "error",
|
|
104
|
+
crossSliceDependency: "warn",
|
|
105
|
+
deepNesting: "info",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -9,12 +9,14 @@ import { fsdPreset, FSD_HIERARCHY } from "./fsd";
|
|
|
9
9
|
import { cleanPreset, CLEAN_HIERARCHY } from "./clean";
|
|
10
10
|
import { hexagonalPreset, HEXAGONAL_HIERARCHY } from "./hexagonal";
|
|
11
11
|
import { atomicPreset, ATOMIC_HIERARCHY } from "./atomic";
|
|
12
|
+
import { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
|
|
12
13
|
|
|
13
14
|
// Re-export
|
|
14
15
|
export { fsdPreset, FSD_HIERARCHY } from "./fsd";
|
|
15
16
|
export { cleanPreset, CLEAN_HIERARCHY } from "./clean";
|
|
16
17
|
export { hexagonalPreset, HEXAGONAL_HIERARCHY } from "./hexagonal";
|
|
17
18
|
export { atomicPreset, ATOMIC_HIERARCHY } from "./atomic";
|
|
19
|
+
export { cqrsPreset, CQRS_HIERARCHY } from "./cqrs";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Mandu 권장 프리셋 (FSD + Clean 조합)
|
|
@@ -263,6 +265,7 @@ export const presets: Record<GuardPreset, PresetDefinition> = {
|
|
|
263
265
|
clean: cleanPreset,
|
|
264
266
|
hexagonal: hexagonalPreset,
|
|
265
267
|
atomic: atomicPreset,
|
|
268
|
+
cqrs: cqrsPreset,
|
|
266
269
|
mandu: manduPreset,
|
|
267
270
|
};
|
|
268
271
|
|
package/src/guard/suggestions.ts
CHANGED
|
@@ -35,6 +35,12 @@ const DOCS: Record<GuardPreset | "default", Record<string, string>> = {
|
|
|
35
35
|
molecules: "https://bradfrost.com/blog/post/atomic-web-design/#molecules",
|
|
36
36
|
organisms: "https://bradfrost.com/blog/post/atomic-web-design/#organisms",
|
|
37
37
|
},
|
|
38
|
+
cqrs: {
|
|
39
|
+
base: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs",
|
|
40
|
+
commands: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs#solution",
|
|
41
|
+
queries: "https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs#solution",
|
|
42
|
+
layers: "https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html",
|
|
43
|
+
},
|
|
38
44
|
mandu: {
|
|
39
45
|
base: "https://github.com/mandujs/mandu/docs/guard",
|
|
40
46
|
layers: "https://github.com/mandujs/mandu/docs/guard#layers",
|
package/src/guard/types.ts
CHANGED