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/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 sourceContent = await readFile(sourceClaudeMd, "utf-8");
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` | **엔티티 설계 → 테스트 완료 5단계 가이드** |
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 (5단계 마스터 가이드)** |
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`: fixtureHint 기반 LLM 생성 활성화 (ANTHROPIC_API_KEY 필요)
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
- ### fixtureHint - LLM 연동 트리거
431
+ ### note - 설명 및 LLM 연동 트리거
432
432
 
433
433
  ```json
434
434
  {
435
435
  "name": "phone",
436
436
  "type": "string",
437
437
  "cone": {
438
- "fixtureHint": "010-XXXX-XXXX 형식의 한국 전화번호"
438
+ "note": "010-XXXX-XXXX 형식의 한국 전화번호"
439
439
  }
440
440
  }
441
441
  ```
442
442
 
443
443
  **동작 방식**:
444
- - `--use-llm` 없을 때: 개발자 참고용 주석 역할만 함
445
- - `--use-llm` 있을 때: Claude API를 호출하여 hint 기반의 실제 값 생성
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` 플래그를 사용하면 `fixtureHint`가 Claude API 호출 트리거로 작동합니다.
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. fixtureHint + LLM ← --use-llm 플래그 시 활성화
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
- - 동일한 `entity:field:hint` 조합은 LLM 재호출하지 않음
493
- - 같은 FixtureGenerator 인스턴스 내에서만 유효 (인메모리)
494
- - `--no-cache`로 비활성화 가능
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
- #### fixtureHint vs fixtureGenerator 선택 기준
503
+ #### note vs fixtureGenerator 선택 기준
502
504
 
503
505
  | 상황 | 추천 |
504
506
  |------|------|
505
507
  | 이메일, 이름, 숫자 등 단순한 값 | `fixtureGenerator` (faker.js) |
506
- | 자기소개, 설명문 등 맥락있는 텍스트 | `fixtureHint` (LLM) |
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 saved = await UserModel.save(user);
44
- return saved.id;
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 saved = await PostModel.save(post);
65
- return saved.id;
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 saved = await CommentModel.save(comment);
88
- return saved.id;
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
- const updated = await PostModel.save({
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 D → SaveParams 변환
2244
- export function taskToSaveParams(task: TaskSubsetD): TaskSaveParams {
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("D", taskId);
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("D", taskId);
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
- const entity = this.entityManager.get(entityName);
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
- // integerbigInteger 아닌 경우 sequence reset을 스킵합니다 (text, uuid 등)
159
- if (!idType || (idType !== "integer" && idType !== "bigInteger")) {
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
- // 번째 인자를 생략하면 기본값 true가 적용되어, 다음 INSERT 시 MAX(id)+1부터 시작합니다.
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 MAX(id) FROM ${tableName}), 1)
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
- // integerbigInteger 아닌 경우 sequence reset을 스킵합니다
623
- if (!idType || (idType !== "integer" && idType !== "bigInteger")) {
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 maxIdResult = await trx(tableName).max("id as max_id").first();
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
- // 시퀀스를 최대 ID설정
640
- await trx.raw(`SELECT setval('${tableName}_id_seq', ?)`, [maxId]);
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) {
@@ -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
- try {
1208
- // 타겟 DB 설정 가져오기
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
- // Knex 인스턴스 생성
1212
- const db = createKnexInstance(dbConfig);
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
- try {
1259
- // Fixture DB 설정 가져오기
1260
- const fixtureDbConfig = Sonamu.dbConfig.fixture;
1257
+ // Fixture DB 설정 가져오기
1258
+ const fixtureDbConfig = Sonamu.dbConfig.fixture;
1261
1259
 
1262
- // Knex 인스턴스 생성
1263
- const fixtureDb = createKnexInstance(fixtureDbConfig);
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
- try {
1315
- // Source DB (production/development) - 읽기 전용
1316
- const sourceDb = DB.getDB("r");
1312
+ // Source DB (production/development) - 읽기 전용
1313
+ const sourceDb = DB.getDB("r");
1317
1314
 
1318
- // Target DB (fixture)
1319
- const fixtureDb = createKnexInstance(Sonamu.dbConfig.fixture);
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
- try {
1363
- // Fixture DB 연결
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