sonamu 0.7.39 → 0.7.41

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.
Files changed (27) hide show
  1. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  2. package/dist/tasks/workflow-manager.js +9 -1
  3. package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
  4. package/dist/template/implementations/generated_http.template.js +3 -2
  5. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  6. package/dist/template/implementations/queries.template.js +10 -2
  7. package/dist/template/implementations/sd.template.d.ts.map +1 -1
  8. package/dist/template/implementations/sd.template.js +34 -21
  9. package/dist/template/implementations/view_form.template.d.ts.map +1 -1
  10. package/dist/template/implementations/view_form.template.js +4 -2
  11. package/dist/template/implementations/view_id_async_select.template.js +2 -2
  12. package/dist/template/zod-converter.js +4 -2
  13. package/dist/testing/fixture-manager.d.ts +18 -6
  14. package/dist/testing/fixture-manager.d.ts.map +1 -1
  15. package/dist/testing/fixture-manager.js +124 -46
  16. package/dist/ui-web/assets/{index-Bfv7V57v.css → index-CWExqVO5.css} +1 -1
  17. package/dist/ui-web/assets/{index-DCf469Xl.js → index-DoZuAOiq.js} +69 -67
  18. package/dist/ui-web/index.html +2 -2
  19. package/package.json +3 -3
  20. package/src/tasks/workflow-manager.ts +10 -0
  21. package/src/template/implementations/generated_http.template.ts +2 -1
  22. package/src/template/implementations/queries.template.ts +23 -12
  23. package/src/template/implementations/sd.template.ts +33 -20
  24. package/src/template/implementations/view_form.template.ts +3 -1
  25. package/src/template/implementations/view_id_async_select.template.ts +1 -1
  26. package/src/template/zod-converter.ts +3 -1
  27. package/src/testing/fixture-manager.ts +167 -59
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>{{projectName}}: Sonamu UI</title>
8
- <script type="module" crossorigin src="/sonamu-ui/assets/index-DCf469Xl.js"></script>
9
- <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-Bfv7V57v.css">
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-DoZuAOiq.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-CWExqVO5.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.7.39",
3
+ "version": "0.7.41",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "typescript",
@@ -113,9 +113,9 @@
113
113
  "exceljs": "^4.4.0",
114
114
  "zod": "^4.1.12",
115
115
  "@sonamu-kit/hmr-hook": "^0.4.1",
116
+ "@sonamu-kit/ts-loader": "^2.1.3",
116
117
  "@sonamu-kit/hmr-runner": "^0.1.1",
117
- "@sonamu-kit/tasks": "^0.1.3",
118
- "@sonamu-kit/ts-loader": "^2.1.3"
118
+ "@sonamu-kit/tasks": "^0.1.3"
119
119
  },
120
120
  "devDependencies": {
121
121
  "@biomejs/biome": "^2.3.10",
@@ -198,6 +198,11 @@ export class WorkflowManager {
198
198
  workflow: Pick<WorkflowMetadata, "id" | "name" | "version">,
199
199
  schedule: WorkflowMetadata["schedules"][number],
200
200
  ) {
201
+ // Worker가 활성화된 노드에서만 처리
202
+ if (!this.#worker) {
203
+ return;
204
+ }
205
+
201
206
  const task = cronSchedule(
202
207
  schedule.expression,
203
208
  (async (
@@ -227,6 +232,11 @@ export class WorkflowManager {
227
232
 
228
233
  // cron task를 중지
229
234
  async unscheduleTask(name: string) {
235
+ // Worker가 활성화된 노드에서만 처리
236
+ if (!this.#worker) {
237
+ return;
238
+ }
239
+
230
240
  const taskItem = this.#scheduledTasks.get(name);
231
241
  if (!taskItem) {
232
242
  console.error("scheduled task not found", name);
@@ -75,7 +75,8 @@ export class Template__generated_http extends Template {
75
75
  );
76
76
  } else if (zodType instanceof z.ZodArray) {
77
77
  return [this.zodTypeToReqDefault((zodType as z.ZodArray<z.ZodType>).element, name)];
78
- } else if (zodType instanceof z.ZodString) {
78
+ } else if (zodType instanceof z.core.$ZodString) {
79
+ // NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
79
80
  if (name.endsWith("_at") || name.endsWith("_date") || name === "range") {
80
81
  return "2000-01-01";
81
82
  } else {
@@ -90,22 +90,33 @@ ${functions.join("\n\n")}
90
90
  );
91
91
  }
92
92
 
93
+ // tanstack-query API가 없으면 헬퍼 함수와 import를 포함하지 않습니다.
94
+ // 새 프로젝트에서 첫 빌드 시 sync가 아직 안 되어 namespace가 비어있을 수 있고,
95
+ // 이때 createSSRQuery가 unused로 빌드 에러가 발생하는 것을 방지합니다.
96
+ const hasQueries = namespaces.length > 0;
97
+
93
98
  return {
94
99
  ...this.getTargetAndPath(),
95
100
  body: namespaces.join("\n\n"),
96
101
  importKeys: diff(unique(importKeys), typeParamNames),
97
- customHeaders: [
98
- "/** biome-ignore-all lint: generated는 무시 */",
99
- "/** biome-ignore-all assist: generated는 무시 */",
100
- "",
101
- `import type { SSRQuery } from 'sonamu/ssr';`,
102
- "",
103
- `// SSRQuery 헬퍼 함수`,
104
- `function createSSRQuery(modelName: string, methodName: string, params: any[], serviceKey: [string, string]): SSRQuery {`,
105
- ` return { modelName, methodName, params, serviceKey, __brand: 'SSRQuery' } as SSRQuery;`,
106
- `}`,
107
- "",
108
- ],
102
+ customHeaders: hasQueries
103
+ ? [
104
+ "/** biome-ignore-all lint: generated는 무시 */",
105
+ "/** biome-ignore-all assist: generated는 무시 */",
106
+ "",
107
+ `import type { SSRQuery } from 'sonamu/ssr';`,
108
+ "",
109
+ `// SSRQuery 헬퍼 함수`,
110
+ `function createSSRQuery(modelName: string, methodName: string, params: any[], serviceKey: [string, string]): SSRQuery {`,
111
+ ` return { modelName, methodName, params, serviceKey, __brand: 'SSRQuery' } as SSRQuery;`,
112
+ `}`,
113
+ "",
114
+ ]
115
+ : [
116
+ "/** biome-ignore-all lint: generated는 무시 */",
117
+ "/** biome-ignore-all assist: generated는 무시 */",
118
+ "",
119
+ ],
109
120
  };
110
121
  }
111
122
  }
@@ -38,27 +38,27 @@ export class Template__sd extends Template {
38
38
  ? `
39
39
  import { Sonamu } from "sonamu";
40
40
 
41
- const DEFAULT_LOCALE = "${defaultLocale}";
42
- const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)};
43
- function getCurrentLocale(): string {
41
+ const DEFAULT_LOCALE = "${defaultLocale}" as const;
42
+ const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)} as const;
43
+ function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
44
44
  try {
45
45
  const ctx = Sonamu.getContext();
46
- return ctx.locale ?? DEFAULT_LOCALE;
46
+ return ctx.locale as (typeof SUPPORTED_LOCALES)[number] ?? DEFAULT_LOCALE;
47
47
  } catch (_) {
48
48
  return DEFAULT_LOCALE;
49
49
  }
50
50
  }
51
51
  `.trim()
52
52
  : `
53
- const DEFAULT_LOCALE = "${defaultLocale}";
54
- const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)};
55
- let _currentLocale = DEFAULT_LOCALE;
53
+ const DEFAULT_LOCALE = "${defaultLocale}" as const;
54
+ const SUPPORTED_LOCALES = ${JSON.stringify(supportedLocales)} as const;
55
+ let _currentLocale: (typeof SUPPORTED_LOCALES)[number] = DEFAULT_LOCALE;
56
56
 
57
- export function setLocale(locale: string) {
57
+ export function setLocale(locale: (typeof SUPPORTED_LOCALES)[number]) {
58
58
  _currentLocale = locale;
59
59
  }
60
60
 
61
- export function getCurrentLocale(): string {
61
+ export function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
62
62
  return _currentLocale;
63
63
  }
64
64
  `.trim();
@@ -136,33 +136,46 @@ export function SD<K extends DictKey>(key: K): SDReturnType<K> {
136
136
  * const EN = SD.locale("en");
137
137
  * EN("common.save") // → "Save"
138
138
  */
139
- SD.locale = (locale: string) => <K extends DictKey>(key: K): SDReturnType<K> => {
139
+ SD.locale = (locale: (typeof SUPPORTED_LOCALES)[number]) => <K extends DictKey>(key: K): SDReturnType<K> => {
140
140
  return getDictValue(key, locale);
141
141
  };
142
142
 
143
+ // Localized 가능한 Column 타입 계산
144
+ type LocalizedBaseColumn<T> = {
145
+ [K in keyof T & string]: K extends \`\${infer Base}_\${(typeof SUPPORTED_LOCALES)[number]}\` ? Base : K;
146
+ }[keyof T & string];
147
+
143
148
  /**
144
149
  * locale에 따라 적절한 컬럼 값을 반환합니다.
145
150
  * DB에 name, name_ko, name_en처럼 localized column이 있을 때 사용합니다.
146
- *
147
- * 우선순위 (ko locale): column_ko → column → column_en
148
- * 우선순위 (en locale): column_en → column → column_ko
151
+ *
152
+ * 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 ko, 사용자의 로케일은 jp일 때): column_jp → column → column_ko → column_en
153
+ * 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 en, 사용자의 로케일은 ko일 때): column_ko → column → column_en → column_jp
149
154
  *
150
155
  * @example
151
156
  * localizedColumn(tag, "name")
152
157
  */
153
- export function localizedColumn<T extends Record<string, unknown>, K extends keyof T & string>(
158
+ export function localizedColumn<T extends Record<string, unknown>, K extends LocalizedBaseColumn<T>>(
154
159
  row: T,
155
160
  column: K,
156
161
  ): string | undefined {
157
162
  const locale = getCurrentLocale();
158
- const otherLocales = SUPPORTED_LOCALES.filter((l: string) => l !== locale);
159
- const localizedKey = (column: K, locale: string) => \`\${String(column)}_\${locale}\`;
160
- const keys = [localizedKey(column, locale), column, ...otherLocales.map((l) => localizedKey(column, l))];
163
+ const otherLocales = SUPPORTED_LOCALES.filter((l: string) => l !== locale && l !== DEFAULT_LOCALE);
164
+ const localizedKey = (column: K, locale: (typeof SUPPORTED_LOCALES)[number]) => \`\${column}_\${locale}\`;
165
+ const keys = [
166
+ localizedKey(column, locale),
167
+ column,
168
+ localizedKey(column, DEFAULT_LOCALE),
169
+ ...otherLocales.map((l) => localizedKey(column, l)),
170
+ ];
161
171
 
162
172
  for (const key of keys) {
163
- const value = row[key];
164
- if (value != null && value !== "") {
165
- return String(value);
173
+ if (!(key in row)) {
174
+ continue;
175
+ }
176
+
177
+ if (row[key] !== null && row[key] !== undefined && row[key] !== "") {
178
+ return String(row[key]);
166
179
  }
167
180
  }
168
181
 
@@ -138,7 +138,9 @@ export class Template__view_form extends Template {
138
138
  value = Object.keys(col.zodType.enum)[0];
139
139
  } else if (col.zodType instanceof z.ZodBoolean) {
140
140
  value = false;
141
- } else if (col.zodType instanceof z.ZodString) {
141
+ } else if (col.zodType instanceof z.core.$ZodString) {
142
+ // NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
143
+ // FIXME: email이나 url 타입 등에 대한 처리가 필요함
142
144
  if (col.renderType === "string-datetime") {
143
145
  value = "now()";
144
146
  } else {
@@ -147,8 +147,8 @@ export function ${names.capital}IdAsyncSelect<T extends ${names.capital}SubsetKe
147
147
  return (
148
148
  <MultiSelect
149
149
  options={options}
150
+ value={multiValue}
150
151
  onValueChange={handleMultiChange}
151
- defaultValue={multiValue}
152
152
  placeholder={placeholder}
153
153
  disabled={disabled}
154
154
  className={className}
@@ -627,7 +627,9 @@ export function zodTypeToRenderingNode(
627
627
  function resolveRenderType(key: string, zodType: z.ZodTypeAny): RenderingNode["renderType"] {
628
628
  if (zodType instanceof z.ZodDate) {
629
629
  return "datetime";
630
- } else if (zodType instanceof z.ZodString) {
630
+ } else if (zodType instanceof z.core.$ZodString) {
631
+ // NOTE: z.ZodString으로 비교하면 z.url(), z.email() 등의 타입에서 문제가 생기므로 z.core.$ZodString으로 비교함
632
+ // FIXME: email이나 url 타입 등에 대한 처리가 필요함
631
633
  if (zodType.description === "SQLDateTimeString") {
632
634
  return "string-datetime";
633
635
  } else if (key.endsWith("date")) {
@@ -14,6 +14,7 @@ import { type UBRef, UpsertBuilder } from "../database/upsert-builder";
14
14
  import type { Entity } from "../entity/entity";
15
15
  import { EntityManager } from "../entity/entity-manager";
16
16
  import {
17
+ type BelongsToOneRelationProp,
17
18
  type DatabaseSchemaExtend,
18
19
  type EntityProp,
19
20
  type FixtureImportResult,
@@ -26,6 +27,7 @@ import {
26
27
  isRelationProp,
27
28
  isVirtualProp,
28
29
  type ManyToManyRelationProp,
30
+ type OneToOneRelationProp,
29
31
  } from "../types/types";
30
32
  import { RelationGraph } from "./_relation-graph";
31
33
 
@@ -65,7 +67,6 @@ export class FixtureManagerClass {
65
67
  // UpsertBuilder 기반 import를 위한 상태
66
68
  private builder: UpsertBuilder = new UpsertBuilder();
67
69
  private fixtureRefMap: Map<string, UBRef> = new Map();
68
- private uuidToFixtureId: Map<string, string> = new Map();
69
70
  private skippedFixtures: Map<string, { entityId: string; existingId: number }> = new Map();
70
71
 
71
72
  init() {
@@ -422,8 +423,13 @@ export class FixtureManagerClass {
422
423
 
423
424
  /**
424
425
  * 1. RelationGraph로 fixture 단위 삽입 순서 계산 (self-reference 포함)
425
- * 2. 순서대로 UpsertBuilder에 등록 (UBRef로 참조 관계 표현)
426
- * 3. 테이블별 upsert 실행 (ID는 DB 자동 할당)
426
+ * 2. 테이블별 레벨별로 UpsertBuilder에 등록 upsert 실행
427
+ * 3. 순서 기반 uuid→id 매핑 (UpsertBuilder가 uuid를 DB 저장하지 않으므로)
428
+ *
429
+ * UpsertBuilder는 self-reference가 있으면 buildInsertLevels()로 재정렬하여
430
+ * 등록 순서와 반환 순서가 달라질 수 있습니다. 이를 방지하기 위해
431
+ * FixtureManager가 레벨별로 나눠서 처리하여 각 upsert 호출에서는
432
+ * self-reference가 없도록 합니다.
427
433
  */
428
434
  async insertFixtures(
429
435
  dbName: keyof SonamuDBConfig,
@@ -434,7 +440,6 @@ export class FixtureManagerClass {
434
440
  // 초기화
435
441
  this.builder = new UpsertBuilder();
436
442
  this.fixtureRefMap = new Map();
437
- this.uuidToFixtureId = new Map();
438
443
  this.skippedFixtures = new Map();
439
444
 
440
445
  const db = createKnexInstance(Sonamu.dbConfig[dbName]);
@@ -445,7 +450,7 @@ export class FixtureManagerClass {
445
450
  this.relationGraph.buildGraph(fixtures);
446
451
  const insertionOrder = this.relationGraph.getInsertionOrder();
447
452
 
448
- // 2. 순서대로 UpsertBuilder에 등록 (override 체크)
453
+ // 2. 스킵할 fixture 먼저 처리 (override 체크)
449
454
  for (const fixtureId of insertionOrder) {
450
455
  const fixture = fixtures.find((f) => f.fixtureId === fixtureId);
451
456
  if (!fixture) continue;
@@ -469,52 +474,82 @@ export class FixtureManagerClass {
469
474
  `Skipped ${fixture.entityId}#${fixture.id} (existing: #${existingId}, override: false)`,
470
475
  ),
471
476
  );
472
- continue;
473
477
  }
474
-
475
- this.registerFixture(fixture);
476
- console.log(
477
- chalk.blue(
478
- `Registered ${fixture.entityId}#${fixture.id}${fixture.override ? ` (override existing: #${fixture.target?.id})` : ""}`,
479
- ),
480
- );
481
478
  }
482
479
 
483
- // 3. 테이블별 upsert 실행
484
- const tableOrder = this.getTableOrder(fixtures);
480
+ // 3. 테이블별 fixture 그룹화 (insertionOrder 순서 기반)
481
+ const fixturesByTable = new Map<string, FixtureRecord[]>();
482
+ const tableOrder: string[] = [];
485
483
 
486
- await db.transaction(async (trx) => {
487
- const insertedIdsByTable = new Map<string, Map<string, number>>();
484
+ for (const fixtureId of insertionOrder) {
485
+ // 스킵된 fixture 제외
486
+ if (this.skippedFixtures.has(fixtureId)) continue;
488
487
 
489
- for (const tableName of tableOrder) {
490
- if (!this.builder.hasTable(tableName)) continue;
488
+ const fixture = fixtures.find((f) => f.fixtureId === fixtureId);
489
+ if (!fixture) continue;
490
+
491
+ const entity = EntityManager.get(fixture.entityId);
492
+ const tableName = entity.table;
491
493
 
492
- // upsert 실행 전 uuid 목록 저장
493
- const table = this.builder.getTable(tableName);
494
- const uuids = table.rows.map((row) => row.uuid as string);
494
+ if (!fixturesByTable.has(tableName)) {
495
+ fixturesByTable.set(tableName, []);
496
+ tableOrder.push(tableName);
497
+ }
498
+ fixturesByTable.get(tableName)?.push(fixture);
499
+ }
495
500
 
496
- console.log(chalk.blue(`Upserting ${tableName} with ${uuids.length} rows`));
497
- await this.builder.upsert(trx, tableName);
501
+ await db.transaction(async (trx) => {
502
+ const insertedIdsByTable = new Map<string, Map<string, number>>();
498
503
 
499
- // upsert된 row들의 uuid -> id 매핑 구축
500
- if (uuids.length > 0) {
501
- const uuidToId = new Map<string, number>();
502
- const rows = await trx(tableName as string)
503
- .select("uuid", "id")
504
- .whereIn("uuid", uuids);
504
+ // 4. 테이블별 레벨별 처리
505
+ for (const tableName of tableOrder) {
506
+ const tableFixtures = fixturesByTable.get(tableName) ?? [];
507
+ const levels = this.groupFixturesByLevel(tableFixtures);
505
508
 
506
- for (const row of rows) {
507
- uuidToId.set(row.uuid, row.id);
509
+ for (const levelFixtures of levels) {
510
+ // 해당 레벨의 fixture들 register
511
+ for (const fixture of levelFixtures) {
512
+ this.registerFixture(fixture, insertedIdsByTable);
513
+ console.log(
514
+ chalk.blue(
515
+ `Registered ${fixture.entityId}#${fixture.id}${fixture.override ? ` (override)` : ""}`,
516
+ ),
517
+ );
508
518
  }
509
519
 
510
- insertedIdsByTable.set(tableName, uuidToId);
520
+ // upsert 실행 전 uuid 목록 저장
521
+ const table = this.builder.getTable(tableName);
522
+ const uuids = table.rows.map((row) => row.uuid as string);
523
+
524
+ console.log(
525
+ chalk.blue(
526
+ `Upserting ${tableName} with ${uuids.length} rows (level ${levels.indexOf(levelFixtures) + 1}/${levels.length})`,
527
+ ),
528
+ );
529
+ const ids = await this.builder.upsert(trx, tableName as keyof DatabaseSchemaExtend);
530
+
531
+ // 순서 기반 uuid -> id 매핑
532
+ // self-reference가 없으므로 등록 순서 = 반환 순서 보장
533
+ if (uuids.length > 0 && uuids.length === ids.length) {
534
+ const existingMap = insertedIdsByTable.get(tableName) ?? new Map<string, number>();
535
+ for (let i = 0; i < uuids.length; i++) {
536
+ existingMap.set(uuids[i], ids[i]);
537
+ }
538
+ insertedIdsByTable.set(tableName, existingMap);
539
+ } else if (uuids.length !== ids.length) {
540
+ console.warn(
541
+ chalk.yellow(
542
+ `Warning: uuid count (${uuids.length}) != id count (${ids.length}) for ${tableName}`,
543
+ ),
544
+ );
545
+ }
511
546
  }
512
547
  }
513
548
 
514
- // 4. ManyToMany 관계 처리
549
+ // 5. ManyToMany 관계 처리
515
550
  await this.processManyToManyRelations(trx, fixtures, insertedIdsByTable);
516
551
 
517
- // 5. 결과 수집
552
+ // 6. 결과 수집
518
553
  for (const fixture of fixtures) {
519
554
  const entity = EntityManager.get(fixture.entityId);
520
555
 
@@ -555,8 +590,12 @@ export class FixtureManagerClass {
555
590
 
556
591
  /**
557
592
  * FixtureRecord를 UpsertBuilder에 등록
593
+ * @param insertedIdsByTable 이미 upsert된 테이블의 uuid→id 매핑 (레벨별 처리 시 사용)
558
594
  */
559
- private registerFixture(fixture: FixtureRecord): UBRef {
595
+ private registerFixture(
596
+ fixture: FixtureRecord,
597
+ insertedIdsByTable?: Map<string, Map<string, number>>,
598
+ ): UBRef {
560
599
  const entity = EntityManager.get(fixture.entityId);
561
600
  const row: Record<string, unknown> = {};
562
601
 
@@ -571,8 +610,13 @@ export class FixtureManagerClass {
571
610
  continue;
572
611
  }
573
612
 
574
- // id/uuid 처리: Override 모드일 때만 기존 값 사용
575
- if (propName === "id" || propName === "uuid") {
613
+ // Generated column은 INSERT에서 제외 (DB가 자동 생성)
614
+ if ("generated" in prop && prop.generated) {
615
+ continue;
616
+ }
617
+
618
+ // id 처리: Override 모드일 때만 기존 값 사용
619
+ if (propName === "id") {
576
620
  if (isOverrideMode && existingRecord) {
577
621
  // Override: 기존 레코드의 값 사용 → UPDATE
578
622
  row[propName] = existingRecord.columns[propName]?.value;
@@ -598,8 +642,18 @@ export class FixtureManagerClass {
598
642
  } else {
599
643
  const relatedRef = this.fixtureRefMap.get(relatedFixtureId);
600
644
  if (relatedRef) {
601
- // 이미 등록된 fixture 참조 UBRef 사용
602
- row[`${propName}_id`] = relatedRef;
645
+ // 이미 upsert된 같은 테이블 fixture 확인
646
+ const relatedEntity = EntityManager.get(prop.with);
647
+ const relatedInsertedIds = insertedIdsByTable?.get(relatedEntity.table);
648
+ const actualId = relatedInsertedIds?.get(relatedRef.uuid);
649
+
650
+ if (actualId !== undefined) {
651
+ // 이미 upsert됨 → 실제 ID 사용
652
+ row[`${propName}_id`] = actualId;
653
+ } else {
654
+ // 아직 upsert 안됨 → UBRef 사용
655
+ row[`${propName}_id`] = relatedRef;
656
+ }
603
657
  } else {
604
658
  // fixtures에 포함되지 않은 레코드 → ID 그대로 사용
605
659
  row[`${propName}_id`] = relatedId;
@@ -619,7 +673,6 @@ export class FixtureManagerClass {
619
673
  console.log(chalk.blue(`Registering ${entity.table} - ${inspect(row, false, null, true)}`));
620
674
  const ref = this.builder.register(entity.table, row);
621
675
  this.fixtureRefMap.set(fixture.fixtureId, ref);
622
- this.uuidToFixtureId.set(ref.uuid, fixture.fixtureId);
623
676
 
624
677
  return ref;
625
678
  }
@@ -648,24 +701,6 @@ export class FixtureManagerClass {
648
701
  }
649
702
  }
650
703
 
651
- /**
652
- * 테이블 순서 추출 (fixtures에 포함된 테이블만)
653
- */
654
- private getTableOrder(fixtures: FixtureRecord[]): (keyof DatabaseSchemaExtend)[] {
655
- const tables: string[] = [];
656
- const seen = new Set<string>();
657
-
658
- for (const fixture of fixtures) {
659
- const entity = EntityManager.get(fixture.entityId);
660
- if (!seen.has(entity.table)) {
661
- seen.add(entity.table);
662
- tables.push(entity.table);
663
- }
664
- }
665
-
666
- return tables as (keyof DatabaseSchemaExtend)[];
667
- }
668
-
669
704
  private async processManyToManyRelations(
670
705
  trx: Knex.Transaction,
671
706
  fixtures: FixtureRecord[],
@@ -746,6 +781,79 @@ export class FixtureManagerClass {
746
781
  }
747
782
  }
748
783
 
784
+ /**
785
+ * 같은 테이블 내 fixture들을 self-reference 레벨별로 분할
786
+ * - self-reference가 없는 fixture들: Level 0
787
+ * - Level 0을 참조하는 fixture들: Level 1
788
+ * - 반복
789
+ *
790
+ * UpsertBuilder가 self-reference가 있으면 buildInsertLevels()로 재정렬하여
791
+ * 등록 순서와 반환 순서가 달라질 수 있습니다.
792
+ * 이를 방지하기 위해 FixtureManager가 레벨별로 나눠서 처리합니다.
793
+ */
794
+ private groupFixturesByLevel(fixtures: FixtureRecord[]): FixtureRecord[][] {
795
+ if (fixtures.length === 0) {
796
+ return [];
797
+ }
798
+
799
+ const entity = EntityManager.get(fixtures[0].entityId);
800
+
801
+ // self-reference relation prop 찾기
802
+ const selfRefProps = entity.props.filter(
803
+ (p): p is BelongsToOneRelationProp | OneToOneRelationProp =>
804
+ isRelationProp(p) &&
805
+ (isBelongsToOneRelationProp(p) || (isOneToOneRelationProp(p) && p.hasJoinColumn)) &&
806
+ p.with === entity.id,
807
+ );
808
+
809
+ if (selfRefProps.length === 0) {
810
+ // self-reference 없음 → 단일 레벨
811
+ return [fixtures];
812
+ }
813
+
814
+ // 레벨별 분할 (topological sort)
815
+ const levels: FixtureRecord[][] = [];
816
+ const remaining = new Set(fixtures.map((f) => f.fixtureId));
817
+ const processed = new Set<string>();
818
+
819
+ while (remaining.size > 0) {
820
+ const currentLevel: FixtureRecord[] = [];
821
+
822
+ for (const fixture of fixtures) {
823
+ if (!remaining.has(fixture.fixtureId)) continue;
824
+
825
+ // self-reference가 모두 이미 처리됐거나 null인 경우
826
+ const canProcess = selfRefProps.every((prop) => {
827
+ const refId = fixture.columns[prop.name]?.value as number | null;
828
+ if (refId === null || refId === undefined) return true;
829
+ const refFixtureId = `${prop.with}#${refId}`;
830
+ // 이미 처리됐거나, 현재 fixtures에 포함되지 않은 경우 (외부 참조)
831
+ return processed.has(refFixtureId) || !remaining.has(refFixtureId);
832
+ });
833
+
834
+ if (canProcess) {
835
+ currentLevel.push(fixture);
836
+ }
837
+ }
838
+
839
+ if (currentLevel.length === 0) {
840
+ const remainingIds = Array.from(remaining).join(", ");
841
+ throw new Error(
842
+ `Circular self-reference detected in ${entity.table}. Remaining fixtures: ${remainingIds}`,
843
+ );
844
+ }
845
+
846
+ for (const fixture of currentLevel) {
847
+ remaining.delete(fixture.fixtureId);
848
+ processed.add(fixture.fixtureId);
849
+ }
850
+
851
+ levels.push(currentLevel);
852
+ }
853
+
854
+ return levels;
855
+ }
856
+
749
857
  private async checkUniqueViolation(db: Knex, entity: Entity, fixture: FixtureRecord) {
750
858
  const _uniqueIndexes = entity.indexes?.filter((i) => i.type === "unique") ?? [];
751
859