sonamu 0.8.2 → 0.8.4
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/dist/bin/cli.js +6 -3
- package/dist/entity/entity-manager.d.ts +22 -0
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/testing/fixture-generator.d.ts +13 -0
- package/dist/testing/fixture-generator.d.ts.map +1 -1
- package/dist/testing/fixture-generator.js +114 -4
- package/dist/testing/fixture-manager.d.ts.map +1 -1
- package/dist/testing/fixture-manager.js +17 -11
- package/dist/types/types.d.ts +64 -0
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -1
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +24 -23
- package/package.json +1 -1
- package/src/bin/cli.ts +7 -1
- package/src/skills/sonamu/SKILL.md +2 -2
- package/src/skills/sonamu/fixture-cli.md +15 -13
- package/src/skills/sonamu/testing.md +13 -12
- package/src/skills/sonamu/workflow.md +1 -1
- package/src/testing/fixture-generator.ts +152 -2
- package/src/testing/fixture-manager.ts +33 -10
- package/src/types/types.ts +2 -0
- package/src/ui/api.ts +27 -30
package/src/bin/cli.ts
CHANGED
|
@@ -964,6 +964,7 @@ async function skills_sync() {
|
|
|
964
964
|
await skills_sync_to(path.join(os.homedir(), ".claude"), sourceSkillsDir, sourceClaudeMd, {
|
|
965
965
|
useSymlink: false,
|
|
966
966
|
copyProjectTemplates: false,
|
|
967
|
+
isGlobal: true,
|
|
967
968
|
});
|
|
968
969
|
console.log(chalk.cyan(`\n Global sync complete → ~/.claude/skills/sonamu/`));
|
|
969
970
|
console.log(chalk.dim(` These skills are available in all Claude Code sessions.`));
|
|
@@ -994,6 +995,7 @@ async function skills_sync_to(
|
|
|
994
995
|
useSymlink: boolean;
|
|
995
996
|
copyProjectTemplates: boolean;
|
|
996
997
|
sourceBase?: string;
|
|
998
|
+
isGlobal?: boolean;
|
|
997
999
|
},
|
|
998
1000
|
) {
|
|
999
1001
|
const targetSkillsDir = path.join(claudeDir, "skills", "sonamu");
|
|
@@ -1050,7 +1052,11 @@ async function skills_sync_to(
|
|
|
1050
1052
|
if (await exists(sourceClaudeMd)) {
|
|
1051
1053
|
try {
|
|
1052
1054
|
const targetClaudeMd = path.join(claudeDir, "CLAUDE.md");
|
|
1053
|
-
const
|
|
1055
|
+
const rawContent = await readFile(sourceClaudeMd, "utf-8");
|
|
1056
|
+
// 글로벌 모드에서는 상대 경로를 절대 경로로 변환합니다
|
|
1057
|
+
const sourceContent = options.isGlobal
|
|
1058
|
+
? rawContent.replaceAll(".claude/skills/sonamu/", "~/.claude/skills/sonamu/")
|
|
1059
|
+
: rawContent;
|
|
1054
1060
|
|
|
1055
1061
|
if (await exists(targetClaudeMd)) {
|
|
1056
1062
|
const targetContent = await readFile(targetClaudeMd, "utf-8");
|
|
@@ -249,7 +249,7 @@ confirmed → cancelled (환자/의사 취소, 24시간 전까지만)
|
|
|
249
249
|
|
|
250
250
|
| Skill | 파일 | 용도 |
|
|
251
251
|
|-------|------|------|
|
|
252
|
-
| **전체 워크플로우** | `workflow.md` | **엔티티 설계 →
|
|
252
|
+
| **전체 워크플로우** | `workflow.md` | **엔티티 설계 → Frontend 개발 7단계 가이드** |
|
|
253
253
|
| 프로젝트 생성 | `create-sonamu.md` | create-sonamu CLI 옵션 |
|
|
254
254
|
| 프로젝트 초기화 | `project-init.md` | 프로젝트 생성 여부 확인, 대화 흐름 |
|
|
255
255
|
| 프로젝트 설정 | `config.md` | .env, sonamu.config.ts 설정 |
|
|
@@ -276,7 +276,7 @@ confirmed → cancelled (환자/의사 취소, 24시간 전까지만)
|
|
|
276
276
|
|
|
277
277
|
| 작업 | 참고 Skill |
|
|
278
278
|
|------|-----------|
|
|
279
|
-
| **처음부터 전체 시스템 개발** | **workflow.md (
|
|
279
|
+
| **처음부터 전체 시스템 개발** | **workflow.md (7단계 마스터 가이드)** |
|
|
280
280
|
| 프로젝트 생성 | create-sonamu, project-init |
|
|
281
281
|
| 프로젝트 설정 | config |
|
|
282
282
|
| Sonamu 로컬 개발 설정 | config |
|
|
@@ -93,7 +93,7 @@ pnpm sonamu fixture gen --include User --count 10 --save-to none
|
|
|
93
93
|
- `--exclude <entities>`: --all과 함께 사용, 제외할 Entity
|
|
94
94
|
- `--count <number>`: 각 Entity별 생성 개수 (기본값: 5)
|
|
95
95
|
- `--save-to <target>`: 저장 방식 - `db` | `file` | `file:name.json` | `none`
|
|
96
|
-
- `--use-llm`:
|
|
96
|
+
- `--use-llm`: cone.note 기반 LLM 생성 활성화 (ANTHROPIC_API_KEY 필요)
|
|
97
97
|
- `--no-cache`: LLM 캐시 비활성화 (기본값: 캐시 ON)
|
|
98
98
|
|
|
99
99
|
---
|
|
@@ -428,39 +428,39 @@ Entity JSON에 `cone` 메타데이터를 추가하면 fixture 생성을 더욱
|
|
|
428
428
|
}
|
|
429
429
|
```
|
|
430
430
|
|
|
431
|
-
###
|
|
431
|
+
### note - 설명 및 LLM 연동 트리거
|
|
432
432
|
|
|
433
433
|
```json
|
|
434
434
|
{
|
|
435
435
|
"name": "phone",
|
|
436
436
|
"type": "string",
|
|
437
437
|
"cone": {
|
|
438
|
-
"
|
|
438
|
+
"note": "010-XXXX-XXXX 형식의 한국 전화번호"
|
|
439
439
|
}
|
|
440
440
|
}
|
|
441
441
|
```
|
|
442
442
|
|
|
443
443
|
**동작 방식**:
|
|
444
|
-
- `--use-llm` 없을 때:
|
|
445
|
-
- `--use-llm` 있을 때: Claude API를 호출하여
|
|
444
|
+
- `--use-llm` 없을 때: 개발자/LLM 참고용 설명 역할만 함 (cone-generator가 읽어 메타데이터 생성 시 활용)
|
|
445
|
+
- `--use-llm` 있을 때: fixture gen이 Claude API를 호출하여 note 내용 기반의 실제 값 생성
|
|
446
446
|
|
|
447
447
|
**용도**:
|
|
448
448
|
- 단순 faker.js로 표현하기 어려운 맥락있는 텍스트 (자기소개, 설명문 등)
|
|
449
|
-
- 개발자에게 생성 패턴 설명
|
|
449
|
+
- 개발자에게 필드의 의미와 생성 패턴 설명
|
|
450
450
|
- 길이 제한 없음 (짧은 패턴 또는 긴 설명 모두 가능)
|
|
451
451
|
|
|
452
452
|
---
|
|
453
453
|
|
|
454
454
|
### LLM 기반 데이터 생성
|
|
455
455
|
|
|
456
|
-
`--use-llm` 플래그를 사용하면 `
|
|
456
|
+
`--use-llm` 플래그를 사용하면 `cone.note`가 Claude API 호출 트리거로 작동합니다.
|
|
457
457
|
|
|
458
458
|
#### 우선순위 체인
|
|
459
459
|
|
|
460
460
|
```
|
|
461
461
|
1. override 값 (generate() 호출 시 전달)
|
|
462
462
|
2. fixtureGenerator (faker.js 표현식)
|
|
463
|
-
3.
|
|
463
|
+
3. cone.note + LLM ← --use-llm 플래그 시 활성화
|
|
464
464
|
4. fixtureDefault (고정 기본값)
|
|
465
465
|
5. 타입별 기본값 (자동 생성)
|
|
466
466
|
```
|
|
@@ -489,21 +489,23 @@ export default defineConfig({
|
|
|
489
489
|
|
|
490
490
|
#### 캐싱 동작
|
|
491
491
|
|
|
492
|
-
-
|
|
493
|
-
-
|
|
494
|
-
-
|
|
492
|
+
- `useLLM=true` 시 하나의 row에서 LLM 대상 필드 전체를 **단일 LLM 호출**로 생성 (필드별 개별 호출 아님)
|
|
493
|
+
- 단일 호출 덕분에 `name`, `name_en`, `name_cn`, `email` 등 연관 필드 간 일관성이 자동으로 보장됨
|
|
494
|
+
- 생성된 결과는 `rowKey:fieldName` 키로 인메모리 캐시에 저장되어, 같은 row 내 다음 필드 처리 시 즉시 반환
|
|
495
|
+
- 캐시는 같은 FixtureGenerator 인스턴스 내에서만 유효
|
|
496
|
+
- `--no-cache`로 캐시 비활성화 가능 (단, row 단위 생성 방식 자체는 유지됨)
|
|
495
497
|
|
|
496
498
|
#### Fallback 동작
|
|
497
499
|
|
|
498
500
|
- API 키 없음 → fixtureDefault 또는 타입 기본값으로 fallback (에러 없음)
|
|
499
501
|
- LLM 호출 실패 → 동일하게 fallback (콘솔 경고만 출력)
|
|
500
502
|
|
|
501
|
-
####
|
|
503
|
+
#### note vs fixtureGenerator 선택 기준
|
|
502
504
|
|
|
503
505
|
| 상황 | 추천 |
|
|
504
506
|
|------|------|
|
|
505
507
|
| 이메일, 이름, 숫자 등 단순한 값 | `fixtureGenerator` (faker.js) |
|
|
506
|
-
| 자기소개, 설명문 등 맥락있는 텍스트 | `
|
|
508
|
+
| 자기소개, 설명문 등 맥락있는 텍스트 | `cone.note` + `--use-llm` (LLM) |
|
|
507
509
|
| 특정 값 목록에서 선택 | `fixtureGenerator` (arrayElement) |
|
|
508
510
|
|
|
509
511
|
---
|
|
@@ -40,8 +40,8 @@ export async function createTestUser(params?: Partial<UserSaveParams>): Promise<
|
|
|
40
40
|
name: "Test User",
|
|
41
41
|
...params,
|
|
42
42
|
};
|
|
43
|
-
const
|
|
44
|
-
return
|
|
43
|
+
const [id] = await UserModel.save([user]);
|
|
44
|
+
return id;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// User with dependencies (의존성 체인)
|
|
@@ -61,8 +61,8 @@ export async function createTestPost(
|
|
|
61
61
|
content: "Test content",
|
|
62
62
|
...params,
|
|
63
63
|
};
|
|
64
|
-
const
|
|
65
|
-
return
|
|
64
|
+
const [id] = await PostModel.save([post]);
|
|
65
|
+
return id;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
// Post with dependencies
|
|
@@ -84,8 +84,8 @@ export async function createTestComment(
|
|
|
84
84
|
content: "Test comment",
|
|
85
85
|
...params,
|
|
86
86
|
};
|
|
87
|
-
const
|
|
88
|
-
return
|
|
87
|
+
const [id] = await CommentModel.save([comment]);
|
|
88
|
+
return id;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// Comment with dependencies
|
|
@@ -228,11 +228,12 @@ describe("PostModel", () => {
|
|
|
228
228
|
test("게시글 수정", async () => {
|
|
229
229
|
const { postId } = await createTestPostWithDeps();
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
await PostModel.save([{
|
|
232
232
|
id: postId,
|
|
233
233
|
title: "Updated Title",
|
|
234
|
-
});
|
|
234
|
+
}]);
|
|
235
235
|
|
|
236
|
+
const updated = await PostModel.findById("A", postId);
|
|
236
237
|
expect(updated.title).toBe("Updated Title");
|
|
237
238
|
});
|
|
238
239
|
});
|
|
@@ -2240,8 +2241,8 @@ findById 결과를 수정 후 다시 save할 때 relation을 FK로 변환해야
|
|
|
2240
2241
|
```typescript
|
|
2241
2242
|
// api/src/testing/test-helpers.ts
|
|
2242
2243
|
|
|
2243
|
-
// Task Subset
|
|
2244
|
-
export function taskToSaveParams(task:
|
|
2244
|
+
// Task Subset A → SaveParams 변환
|
|
2245
|
+
export function taskToSaveParams(task: TaskSubsetA): TaskSaveParams {
|
|
2245
2246
|
const {
|
|
2246
2247
|
program,
|
|
2247
2248
|
project,
|
|
@@ -2289,13 +2290,13 @@ import { createTestTaskWithDeps, taskToSaveParams } from "../../testing/test-hel
|
|
|
2289
2290
|
test("Update - 과제 정보 수정", async () => {
|
|
2290
2291
|
const { taskId } = await createTestTaskWithDeps();
|
|
2291
2292
|
|
|
2292
|
-
const task = await TaskModel.findById("
|
|
2293
|
+
const task = await TaskModel.findById("A", taskId);
|
|
2293
2294
|
await TaskModel.save([{
|
|
2294
2295
|
...taskToSaveParams(task),
|
|
2295
2296
|
title: "수정된 제목",
|
|
2296
2297
|
}]);
|
|
2297
2298
|
|
|
2298
|
-
const updated = await TaskModel.findById("
|
|
2299
|
+
const updated = await TaskModel.findById("A", taskId);
|
|
2299
2300
|
expect(updated.title).toBe("수정된 제목");
|
|
2300
2301
|
});
|
|
2301
2302
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sonamu-workflow
|
|
3
|
-
description: Sonamu 전체 개발 워크플로우. 엔티티 설계부터 Frontend 개발까지 7단계 가이드. Use when starting a new feature or system from scratch.
|
|
3
|
+
description: Sonamu 전체 개발 워크플로우. 엔티티 설계부터 Frontend 개발까지 7단계(PHASE 1~7) 가이드. Use when starting a new feature or system from scratch.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Sonamu 전체 개발 워크플로우
|
|
@@ -38,6 +38,7 @@ export class FixtureGenerator {
|
|
|
38
38
|
private locale: Locale;
|
|
39
39
|
private mappings: FakerMappings;
|
|
40
40
|
private llmCache: Map<string, unknown> = new Map();
|
|
41
|
+
private entityCache: Map<string, Entity> = new Map();
|
|
41
42
|
private options: FixtureGeneratorOptions;
|
|
42
43
|
|
|
43
44
|
constructor(
|
|
@@ -69,9 +70,18 @@ export class FixtureGenerator {
|
|
|
69
70
|
overrides: Record<string, unknown> = {},
|
|
70
71
|
context: GeneratorContext = this.createContext(),
|
|
71
72
|
): Promise<Record<string, unknown>> {
|
|
72
|
-
|
|
73
|
+
// Entity 캐싱: 테스트에서 entity cone 수정이 반영되도록 보장
|
|
74
|
+
let entity = this.entityCache.get(entityName);
|
|
75
|
+
if (!entity) {
|
|
76
|
+
entity = this.entityManager.get(entityName);
|
|
77
|
+
this.entityCache.set(entityName, entity);
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
const tempId = `${entityName}#temp#${Date.now()}`; // 임시 ID
|
|
74
81
|
|
|
82
|
+
// LLM row 단위 생성을 위한 고유 키 (같은 row의 필드들이 동일한 rowKey를 공유)
|
|
83
|
+
const rowKey = this.options.useLLM ? `${entityName}#row#${Date.now()}` : undefined;
|
|
84
|
+
|
|
75
85
|
// 각 prop별 값 생성
|
|
76
86
|
const fixture: Record<string, unknown> = {};
|
|
77
87
|
|
|
@@ -81,6 +91,11 @@ export class FixtureGenerator {
|
|
|
81
91
|
continue;
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
// DB sequence로 관리되는 PK는 생성하지 않음 (DB가 자동 할당)
|
|
95
|
+
if (prop.name === "id" && "cone" in prop && prop.cone?.fixtureStrategy === "sequence") {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
// override가 있으면 사용
|
|
85
100
|
if (prop.name in overrides) {
|
|
86
101
|
fixture[prop.name] = overrides[prop.name];
|
|
@@ -118,7 +133,7 @@ export class FixtureGenerator {
|
|
|
118
133
|
// 2.5. cone.note + LLM 사용
|
|
119
134
|
if (cone?.note && this.options.useLLM) {
|
|
120
135
|
try {
|
|
121
|
-
fixture[prop.name] = await this.generateWithLLM(cone.note, prop, entity);
|
|
136
|
+
fixture[prop.name] = await this.generateWithLLM(cone.note, prop, entity, rowKey);
|
|
122
137
|
continue;
|
|
123
138
|
} catch (error) {
|
|
124
139
|
console.warn(
|
|
@@ -724,6 +739,69 @@ export class FixtureGenerator {
|
|
|
724
739
|
fixtureHint: string,
|
|
725
740
|
prop: EntityProp,
|
|
726
741
|
entity: Entity,
|
|
742
|
+
rowKey?: string,
|
|
743
|
+
): Promise<unknown> {
|
|
744
|
+
// rowKey가 있으면 row 단위 생성 전략 사용
|
|
745
|
+
if (rowKey) {
|
|
746
|
+
const rowCacheKey = `${rowKey}:${prop.name}`;
|
|
747
|
+
|
|
748
|
+
// 이미 이 row에 대한 LLM 호출이 완료된 경우 캐시에서 바로 반환
|
|
749
|
+
if (this.llmCache.has(rowCacheKey)) {
|
|
750
|
+
return this.llmCache.get(rowCacheKey);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 새 row: LLM 대상 prop 전체를 한 번에 생성
|
|
754
|
+
const llmProps = entity.props.filter((p) => {
|
|
755
|
+
if (isRelationProp(p)) return false;
|
|
756
|
+
if (p.cone?.fixtureGenerator) return false;
|
|
757
|
+
if (p.name === "id" && p.cone?.fixtureStrategy === "sequence") return false;
|
|
758
|
+
return !!p.cone?.note;
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// llmProps가 비어있으면 단일 필드 방식으로 fallback
|
|
762
|
+
if (llmProps.length === 0) {
|
|
763
|
+
!isTest() &&
|
|
764
|
+
console.log(
|
|
765
|
+
`[FixtureGenerator] llmProps is empty for ${entity.id}.${prop.name}, using single field fallback`,
|
|
766
|
+
);
|
|
767
|
+
return this.generateSingleWithLLM(fixtureHint, prop, entity);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const apiKey = this.getApiKey();
|
|
771
|
+
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
772
|
+
const { generateText } = await import("ai");
|
|
773
|
+
|
|
774
|
+
const { text } = await generateText({
|
|
775
|
+
model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
|
|
776
|
+
prompt: this.buildRowLLMPrompt(llmProps, entity),
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// 응답을 파싱하여 각 필드에 대한 결과를 캐시에 저장
|
|
780
|
+
const rowResult = this.parseRowLLMResponse(text, llmProps);
|
|
781
|
+
for (const [fieldName, value] of Object.entries(rowResult)) {
|
|
782
|
+
this.llmCache.set(`${rowKey}:${fieldName}`, value);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 요청한 필드의 값 반환 (없으면 단일 필드 fallback)
|
|
786
|
+
if (this.llmCache.has(rowCacheKey)) {
|
|
787
|
+
return this.llmCache.get(rowCacheKey);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// 만약 row 응답에 이 필드가 누락된 경우 단일 필드 fallback
|
|
791
|
+
return this.generateSingleWithLLM(fixtureHint, prop, entity);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// rowKey가 없으면 기존 단일 필드 방식
|
|
795
|
+
return this.generateSingleWithLLM(fixtureHint, prop, entity);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* 단일 필드를 LLM으로 생성합니다 (rowKey 없을 때 fallback용)
|
|
800
|
+
*/
|
|
801
|
+
private async generateSingleWithLLM(
|
|
802
|
+
fixtureHint: string,
|
|
803
|
+
prop: EntityProp,
|
|
804
|
+
entity: Entity,
|
|
727
805
|
): Promise<unknown> {
|
|
728
806
|
const cacheKey = `${entity.id}:${prop.name}:${fixtureHint}`;
|
|
729
807
|
if (this.options.enableLLMCache && this.llmCache.has(cacheKey)) {
|
|
@@ -747,6 +825,78 @@ export class FixtureGenerator {
|
|
|
747
825
|
return value;
|
|
748
826
|
}
|
|
749
827
|
|
|
828
|
+
/**
|
|
829
|
+
* row 전체를 한 번에 생성하는 LLM 프롬프트를 만듭니다.
|
|
830
|
+
*/
|
|
831
|
+
private buildRowLLMPrompt(props: EntityProp[], entity: Entity): string {
|
|
832
|
+
const locale = this.options.locale || "ko";
|
|
833
|
+
const language = locale === "ko" ? "Korean" : locale === "ja" ? "Japanese" : "English";
|
|
834
|
+
|
|
835
|
+
const fieldDescriptions = props
|
|
836
|
+
.map((p) => {
|
|
837
|
+
let desc = `- ${p.name} (${p.type}): ${p.cone?.note ?? ""}`;
|
|
838
|
+
if (
|
|
839
|
+
(p.type === "enum" || p.type === "enum[]") &&
|
|
840
|
+
"id" in p &&
|
|
841
|
+
p.id &&
|
|
842
|
+
entity.enumLabels?.[p.id]
|
|
843
|
+
) {
|
|
844
|
+
const values = Object.keys(entity.enumLabels[p.id]).join(", ");
|
|
845
|
+
desc += ` [allowed values: ${values}]`;
|
|
846
|
+
}
|
|
847
|
+
return desc;
|
|
848
|
+
})
|
|
849
|
+
.join("\n");
|
|
850
|
+
|
|
851
|
+
const outputShape = props.map((p) => ` "${p.name}": <${p.type}>`).join(",\n");
|
|
852
|
+
|
|
853
|
+
return `Generate test fixture data for the ${entity.id} entity. All fields must be coherent and consistent with each other.
|
|
854
|
+
|
|
855
|
+
Entity: ${entity.id}
|
|
856
|
+
Locale: ${locale} (use ${language} for text fields)
|
|
857
|
+
|
|
858
|
+
Fields to generate:
|
|
859
|
+
${fieldDescriptions}
|
|
860
|
+
|
|
861
|
+
Rules:
|
|
862
|
+
- All fields in a single row must be logically consistent (e.g. name/name_en/name_cn should represent the same person)
|
|
863
|
+
- Return ONLY valid JSON, no markdown or explanation
|
|
864
|
+
- Dates in ISO 8601 format
|
|
865
|
+
- Use ${language} for text unless field description says otherwise
|
|
866
|
+
|
|
867
|
+
Return exactly this JSON shape:
|
|
868
|
+
{
|
|
869
|
+
${outputShape}
|
|
870
|
+
}`;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* row LLM 응답을 파싱하여 필드별 값으로 변환합니다.
|
|
875
|
+
*/
|
|
876
|
+
private parseRowLLMResponse(text: string, props: EntityProp[]): Record<string, unknown> {
|
|
877
|
+
const jsonText = text
|
|
878
|
+
.trim()
|
|
879
|
+
.replace(/^```json\s*/i, "")
|
|
880
|
+
.replace(/```\s*$/, "")
|
|
881
|
+
.trim();
|
|
882
|
+
|
|
883
|
+
let parsed: Record<string, unknown>;
|
|
884
|
+
try {
|
|
885
|
+
parsed = JSON.parse(jsonText);
|
|
886
|
+
} catch {
|
|
887
|
+
!isTest() && console.warn("[FixtureGenerator] Failed to parse row LLM response:", text);
|
|
888
|
+
return {};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const result: Record<string, unknown> = {};
|
|
892
|
+
for (const prop of props) {
|
|
893
|
+
if (prop.name in parsed) {
|
|
894
|
+
result[prop.name] = this.parseLLMResponse(String(parsed[prop.name] ?? ""), prop.type);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
|
|
750
900
|
private buildLLMPrompt(hint: string, prop: EntityProp, entity: Entity): string {
|
|
751
901
|
const locale = this.options.locale || "ko";
|
|
752
902
|
const language = locale === "ko" ? "Korean" : locale === "ja" ? "Japanese" : "English";
|
|
@@ -155,8 +155,13 @@ export class FixtureManagerClass {
|
|
|
155
155
|
const idProp = entity.props.find((p) => p.name === "id");
|
|
156
156
|
const idType = idProp?.type;
|
|
157
157
|
|
|
158
|
-
// integer
|
|
159
|
-
|
|
158
|
+
// integer/bigInteger이거나, string이지만 fixtureStrategy=sequence인 경우에만 리셋합니다
|
|
159
|
+
const usesSequence =
|
|
160
|
+
idType === "integer" ||
|
|
161
|
+
idType === "bigInteger" ||
|
|
162
|
+
idProp?.cone?.fixtureStrategy === "sequence";
|
|
163
|
+
|
|
164
|
+
if (!usesSequence) {
|
|
160
165
|
!isTest() &&
|
|
161
166
|
console.log(
|
|
162
167
|
`Skipping sequence reset for ${tableName} (id type: ${idType || "unknown"})`,
|
|
@@ -165,11 +170,12 @@ export class FixtureManagerClass {
|
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
// PostgreSQL 시퀀스를 현재 테이블의 MAX(id)로 리셋합니다.
|
|
168
|
-
//
|
|
173
|
+
// string 타입의 경우 숫자 캐스팅이 필요합니다.
|
|
174
|
+
const maxExpr = idType === "string" ? "MAX(id::bigint)" : "MAX(id)";
|
|
169
175
|
await testDb.raw(`
|
|
170
176
|
SELECT setval(
|
|
171
177
|
pg_get_serial_sequence('public.${tableName}', 'id'),
|
|
172
|
-
COALESCE((SELECT
|
|
178
|
+
COALESCE((SELECT ${maxExpr} FROM ${tableName}), 1)
|
|
173
179
|
)
|
|
174
180
|
`);
|
|
175
181
|
}
|
|
@@ -619,8 +625,13 @@ export class FixtureManagerClass {
|
|
|
619
625
|
const idProp = entity.props.find((p) => p.name === "id");
|
|
620
626
|
const idType = idProp?.type;
|
|
621
627
|
|
|
622
|
-
// integer
|
|
623
|
-
|
|
628
|
+
// integer/bigInteger이거나, string이지만 fixtureStrategy=sequence인 경우에만 리셋합니다
|
|
629
|
+
const usesSequence =
|
|
630
|
+
idType === "integer" ||
|
|
631
|
+
idType === "bigInteger" ||
|
|
632
|
+
idProp?.cone?.fixtureStrategy === "sequence";
|
|
633
|
+
|
|
634
|
+
if (!usesSequence) {
|
|
624
635
|
!isTest() &&
|
|
625
636
|
console.log(
|
|
626
637
|
chalk.gray(
|
|
@@ -631,13 +642,25 @@ export class FixtureManagerClass {
|
|
|
631
642
|
}
|
|
632
643
|
}
|
|
633
644
|
|
|
634
|
-
// 테이블의 최대 ID 조회
|
|
635
|
-
const
|
|
645
|
+
// 테이블의 최대 ID 조회 (string 타입은 숫자 캐스팅 필요)
|
|
646
|
+
const entity2 = EntityManager.getAllEntities().find(
|
|
647
|
+
(e) => e.table === tableName || e.id.toLowerCase() === tableName,
|
|
648
|
+
);
|
|
649
|
+
const idType2 = entity2?.props.find((p) => p.name === "id")?.type;
|
|
650
|
+
const maxIdResult =
|
|
651
|
+
idType2 === "string"
|
|
652
|
+
? await trx
|
|
653
|
+
.raw(`SELECT MAX(id::bigint) as max_id FROM "${tableName}"`)
|
|
654
|
+
.then((r) => r.rows[0])
|
|
655
|
+
: await trx(tableName).max("id as max_id").first();
|
|
636
656
|
const maxId = maxIdResult?.max_id;
|
|
637
657
|
|
|
638
658
|
if (maxId !== null && maxId !== undefined) {
|
|
639
|
-
//
|
|
640
|
-
await trx.raw(`SELECT setval('
|
|
659
|
+
// 시퀀스명을 pg_get_serial_sequence로 안전하게 조회
|
|
660
|
+
await trx.raw(`SELECT setval(pg_get_serial_sequence(?, 'id'), ?)`, [
|
|
661
|
+
tableName,
|
|
662
|
+
maxId,
|
|
663
|
+
]);
|
|
641
664
|
!isTest() && console.log(chalk.green(`Reset sequence for ${tableName}: ${maxId}`));
|
|
642
665
|
}
|
|
643
666
|
} catch (_err) {
|
package/src/types/types.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type Cone = {
|
|
|
30
30
|
// Fixture 생성 관련
|
|
31
31
|
fixtureGenerator?: string; // Faker.js 코드 또는 커스텀 함수
|
|
32
32
|
fixtureDefault?: unknown; // 기본값
|
|
33
|
+
fixtureStrategy?: "sequence"; // string 타입이지만 DB sequence로 관리되는 PK (better-auth 등)
|
|
33
34
|
|
|
34
35
|
// 참조 데이터 관련
|
|
35
36
|
dataSource?: {
|
|
@@ -1051,6 +1052,7 @@ const ConeSchema = z
|
|
|
1051
1052
|
tags: z.array(z.string()).optional(),
|
|
1052
1053
|
fixtureGenerator: z.string().optional(),
|
|
1053
1054
|
fixtureDefault: z.unknown().optional(),
|
|
1055
|
+
fixtureStrategy: z.literal("sequence").optional(),
|
|
1054
1056
|
dataSource: z
|
|
1055
1057
|
.object({
|
|
1056
1058
|
strategy: z.enum(["sample", "ids", "query", "file", "recent", "random"]),
|
package/src/ui/api.ts
CHANGED
|
@@ -1204,13 +1204,13 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1204
1204
|
}>("/api/sonamu/fixture/generate", async (request, reply) => {
|
|
1205
1205
|
const { entity, count = 1, overrides, targetDb = "fixture" } = request.body;
|
|
1206
1206
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
const dbConfig = targetDb === "fixture" ? Sonamu.dbConfig.fixture : Sonamu.dbConfig.test;
|
|
1207
|
+
// 타겟 DB 설정 가져오기
|
|
1208
|
+
const dbConfig = targetDb === "fixture" ? Sonamu.dbConfig.fixture : Sonamu.dbConfig.test;
|
|
1210
1209
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1210
|
+
// Knex 인스턴스 생성
|
|
1211
|
+
const db = createKnexInstance(dbConfig);
|
|
1213
1212
|
|
|
1213
|
+
try {
|
|
1214
1214
|
// FixtureGenerator 생성
|
|
1215
1215
|
const generator = new FixtureGenerator(db, db, targetDb, EntityManager);
|
|
1216
1216
|
|
|
@@ -1223,9 +1223,6 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1223
1223
|
},
|
|
1224
1224
|
]);
|
|
1225
1225
|
|
|
1226
|
-
// Knex 연결 종료
|
|
1227
|
-
await db.destroy();
|
|
1228
|
-
|
|
1229
1226
|
return {
|
|
1230
1227
|
success: true,
|
|
1231
1228
|
entity,
|
|
@@ -1239,6 +1236,8 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1239
1236
|
success: false,
|
|
1240
1237
|
error: error instanceof Error ? error.message : String(error),
|
|
1241
1238
|
};
|
|
1239
|
+
} finally {
|
|
1240
|
+
await db.destroy();
|
|
1242
1241
|
}
|
|
1243
1242
|
});
|
|
1244
1243
|
|
|
@@ -1255,13 +1254,13 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1255
1254
|
}>("/api/sonamu/fixture/explore", async (request, reply) => {
|
|
1256
1255
|
const { entity, strategy, limit = 10, where } = request.body;
|
|
1257
1256
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
const fixtureDbConfig = Sonamu.dbConfig.fixture;
|
|
1257
|
+
// Fixture DB 설정 가져오기
|
|
1258
|
+
const fixtureDbConfig = Sonamu.dbConfig.fixture;
|
|
1261
1259
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1260
|
+
// Knex 인스턴스 생성
|
|
1261
|
+
const fixtureDb = createKnexInstance(fixtureDbConfig);
|
|
1264
1262
|
|
|
1263
|
+
try {
|
|
1265
1264
|
// DataExplorer 생성
|
|
1266
1265
|
const explorer = new DataExplorer(fixtureDb, EntityManager);
|
|
1267
1266
|
|
|
@@ -1271,9 +1270,6 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1271
1270
|
where,
|
|
1272
1271
|
});
|
|
1273
1272
|
|
|
1274
|
-
// Knex 연결 종료
|
|
1275
|
-
await fixtureDb.destroy();
|
|
1276
|
-
|
|
1277
1273
|
return {
|
|
1278
1274
|
success: true,
|
|
1279
1275
|
entity,
|
|
@@ -1287,6 +1283,8 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1287
1283
|
success: false,
|
|
1288
1284
|
error: error instanceof Error ? error.message : String(error),
|
|
1289
1285
|
};
|
|
1286
|
+
} finally {
|
|
1287
|
+
await fixtureDb.destroy();
|
|
1290
1288
|
}
|
|
1291
1289
|
});
|
|
1292
1290
|
|
|
@@ -1311,13 +1309,13 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1311
1309
|
maxDepth = 2,
|
|
1312
1310
|
} = request.body;
|
|
1313
1311
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
const sourceDb = DB.getDB("r");
|
|
1312
|
+
// Source DB (production/development) - 읽기 전용
|
|
1313
|
+
const sourceDb = DB.getDB("r");
|
|
1317
1314
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1315
|
+
// Target DB (fixture)
|
|
1316
|
+
const fixtureDb = createKnexInstance(Sonamu.dbConfig.fixture);
|
|
1320
1317
|
|
|
1318
|
+
try {
|
|
1321
1319
|
// FixtureGenerator 생성
|
|
1322
1320
|
const generator = new FixtureGenerator(sourceDb, fixtureDb, "fixture", EntityManager);
|
|
1323
1321
|
|
|
@@ -1329,9 +1327,6 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1329
1327
|
maxDepth,
|
|
1330
1328
|
});
|
|
1331
1329
|
|
|
1332
|
-
// Knex 연결 종료 (sourceDb는 Sonamu가 관리하므로 destroy하지 않음)
|
|
1333
|
-
await fixtureDb.destroy();
|
|
1334
|
-
|
|
1335
1330
|
return {
|
|
1336
1331
|
success: true,
|
|
1337
1332
|
entity,
|
|
@@ -1345,6 +1340,9 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1345
1340
|
success: false,
|
|
1346
1341
|
error: error instanceof Error ? error.message : String(error),
|
|
1347
1342
|
};
|
|
1343
|
+
} finally {
|
|
1344
|
+
// sourceDb는 Sonamu가 관리하므로 destroy하지 않음
|
|
1345
|
+
await fixtureDb.destroy();
|
|
1348
1346
|
}
|
|
1349
1347
|
});
|
|
1350
1348
|
|
|
@@ -1359,10 +1357,10 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1359
1357
|
}>("/api/sonamu/fixture/clean", async (request, reply) => {
|
|
1360
1358
|
const { entities } = request.body;
|
|
1361
1359
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
const fixtureDb = createKnexInstance(Sonamu.dbConfig.fixture);
|
|
1360
|
+
// Fixture DB 연결
|
|
1361
|
+
const fixtureDb = createKnexInstance(Sonamu.dbConfig.fixture);
|
|
1365
1362
|
|
|
1363
|
+
try {
|
|
1366
1364
|
// 삭제할 Entity 목록 결정
|
|
1367
1365
|
const targetEntities =
|
|
1368
1366
|
entities && entities.length > 0 ? entities : EntityManager.getAllIds();
|
|
@@ -1379,9 +1377,6 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1379
1377
|
`TRUNCATE TABLE ${tableNames.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`,
|
|
1380
1378
|
);
|
|
1381
1379
|
|
|
1382
|
-
// Knex 연결 종료
|
|
1383
|
-
await fixtureDb.destroy();
|
|
1384
|
-
|
|
1385
1380
|
return {
|
|
1386
1381
|
success: true,
|
|
1387
1382
|
cleaned: tableNames,
|
|
@@ -1393,6 +1388,8 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
|
|
|
1393
1388
|
success: false,
|
|
1394
1389
|
error: error instanceof Error ? error.message : String(error),
|
|
1395
1390
|
};
|
|
1391
|
+
} finally {
|
|
1392
|
+
await fixtureDb.destroy();
|
|
1396
1393
|
}
|
|
1397
1394
|
});
|
|
1398
1395
|
|