sonamu 0.8.13 → 0.8.14

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 (96) hide show
  1. package/dist/api/sonamu.d.ts.map +1 -1
  2. package/dist/api/sonamu.js +2 -3
  3. package/dist/auth/auth-generator.d.ts +8 -0
  4. package/dist/auth/auth-generator.d.ts.map +1 -1
  5. package/dist/auth/auth-generator.js +33 -1
  6. package/dist/auth/better-auth-entities.d.ts.map +1 -1
  7. package/dist/auth/better-auth-entities.js +12 -2
  8. package/dist/bin/cli.js +18 -3
  9. package/dist/cone/cone-generator.js +10 -4
  10. package/dist/database/knex.d.ts.map +1 -1
  11. package/dist/database/knex.js +64 -2
  12. package/dist/database/puri.d.ts +9 -1
  13. package/dist/database/puri.d.ts.map +1 -1
  14. package/dist/database/puri.js +42 -1
  15. package/dist/database/puri.types.d.ts +2 -0
  16. package/dist/database/puri.types.d.ts.map +1 -1
  17. package/dist/database/puri.types.js +6 -2
  18. package/dist/entity/entity-manager.d.ts +149 -1
  19. package/dist/entity/entity-manager.d.ts.map +1 -1
  20. package/dist/entity/entity-manager.js +68 -4
  21. package/dist/migration/__tests__/code-generation.search-text.test.js +435 -0
  22. package/dist/migration/code-generation.d.ts.map +1 -1
  23. package/dist/migration/code-generation.js +696 -32
  24. package/dist/migration/migration-set.js +3 -1
  25. package/dist/migration/postgresql-schema-reader.d.ts +16 -2
  26. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  27. package/dist/migration/postgresql-schema-reader.js +281 -7
  28. package/dist/stream/sse.js +5 -3
  29. package/dist/template/__tests__/generated.template.search-text.test.js +99 -0
  30. package/dist/template/generated.template.test-d.js +24 -0
  31. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  32. package/dist/template/implementations/generated.template.js +2 -2
  33. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  34. package/dist/template/implementations/init_types.template.js +11 -3
  35. package/dist/template/zod-converter.d.ts.map +1 -1
  36. package/dist/template/zod-converter.js +6 -2
  37. package/dist/testing/dev-test-routes.d.ts.map +1 -1
  38. package/dist/testing/dev-test-routes.js +5 -3
  39. package/dist/testing/fixture-generator.d.ts +13 -0
  40. package/dist/testing/fixture-generator.d.ts.map +1 -1
  41. package/dist/testing/fixture-generator.js +105 -8
  42. package/dist/testing/fixture-manager.d.ts.map +1 -1
  43. package/dist/testing/fixture-manager.js +19 -2
  44. package/dist/types/__tests__/entity-json-schema-search-text.test.js +256 -0
  45. package/dist/types/types.d.ts +494 -1
  46. package/dist/types/types.d.ts.map +1 -1
  47. package/dist/types/types.js +117 -13
  48. package/dist/ui/api.d.ts.map +1 -1
  49. package/dist/ui/api.js +14 -2
  50. package/dist/ui/cdd-service.d.ts +16 -14
  51. package/dist/ui/cdd-service.d.ts.map +1 -1
  52. package/dist/ui/cdd-service.js +145 -37
  53. package/dist/ui/cdd-types.d.ts +60 -0
  54. package/dist/ui/cdd-types.d.ts.map +1 -0
  55. package/dist/ui/cdd-types.js +3 -0
  56. package/dist/ui-web/assets/index-D4XFBV-f.css +1 -0
  57. package/dist/ui-web/assets/{index-CQ_S40bD.js → index-D_19-Pi4.js} +87 -87
  58. package/dist/ui-web/index.html +2 -2
  59. package/package.json +7 -3
  60. package/src/api/sonamu.ts +1 -2
  61. package/src/auth/auth-generator.ts +38 -0
  62. package/src/auth/better-auth-entities.ts +18 -1
  63. package/src/bin/cli.ts +15 -1
  64. package/src/cone/cone-generator.ts +9 -3
  65. package/src/database/knex.ts +62 -4
  66. package/src/database/puri.ts +71 -0
  67. package/src/database/puri.types.ts +2 -0
  68. package/src/entity/entity-manager.ts +95 -3
  69. package/src/migration/__tests__/code-generation.search-text.test.ts +390 -0
  70. package/src/migration/code-generation.ts +848 -34
  71. package/src/migration/migration-set.ts +2 -0
  72. package/src/migration/postgresql-schema-reader.ts +366 -9
  73. package/src/skills/sonamu/auth-migration.md +80 -0
  74. package/src/skills/sonamu/cdd.md +148 -28
  75. package/src/skills/sonamu/cone.md +16 -0
  76. package/src/skills/sonamu/entity-relations.md +1 -1
  77. package/src/skills/sonamu/fixture-cli.md +4 -0
  78. package/src/skills/sonamu/frontend.md +65 -0
  79. package/src/skills/sonamu/migration.md +3 -1
  80. package/src/skills/sonamu/model.md +28 -0
  81. package/src/skills/sonamu/workflow.md +12 -5
  82. package/src/stream/sse.ts +4 -2
  83. package/src/template/__tests__/generated.template.search-text.test.ts +89 -0
  84. package/src/template/generated.template.test-d.ts +46 -0
  85. package/src/template/implementations/generated.template.ts +4 -1
  86. package/src/template/implementations/init_types.template.ts +20 -5
  87. package/src/template/zod-converter.ts +5 -0
  88. package/src/testing/dev-test-routes.ts +4 -2
  89. package/src/testing/fixture-generator.ts +157 -9
  90. package/src/testing/fixture-manager.ts +15 -1
  91. package/src/types/__tests__/entity-json-schema-search-text.test.ts +179 -0
  92. package/src/types/types.ts +168 -12
  93. package/src/ui/api.ts +24 -1
  94. package/src/ui/cdd-service.ts +195 -55
  95. package/src/ui/cdd-types.ts +73 -0
  96. package/dist/ui-web/assets/index-egkMxKos.css +0 -1
@@ -225,7 +225,10 @@ export class Template__generated extends Template {
225
225
  * - generated 속성이 있는 컬럼 (INSERT/UPDATE 시 값 제공 불가)
226
226
  */
227
227
  const generatedColumns = entity.props
228
- .filter((prop) => prop.type !== "relation" && prop.generated !== undefined)
228
+ .filter(
229
+ (prop) =>
230
+ prop.type !== "relation" && (prop.generated !== undefined || prop.type === "searchText"),
231
+ )
229
232
  .map((prop) => prop.name);
230
233
 
231
234
  const hasMetadata =
@@ -19,9 +19,24 @@ export class Template__init_types extends Template {
19
19
 
20
20
  render({ entityId }: TemplateOptions["init_types"]) {
21
21
  const names = EntityManager.getNamesFromId(entityId);
22
+ const entity = EntityManager.get(entityId);
22
23
 
23
- const hasCreatedAt =
24
- EntityManager.get(entityId).props.find((prop) => prop.name === "created_at") !== undefined;
24
+ const partialColumns = ["id"];
25
+ if (entity.props.some((prop) => prop.name === "created_at")) {
26
+ partialColumns.push("created_at");
27
+ }
28
+
29
+ const writeIneligibleColumns = entity.props
30
+ .filter(
31
+ (prop) =>
32
+ prop.type !== "relation" && (prop.generated !== undefined || prop.type === "searchText"),
33
+ )
34
+ .map((prop) => prop.name);
35
+
36
+ const omitGeneratedColumns =
37
+ writeIneligibleColumns.length > 0
38
+ ? `.omit({ ${writeIneligibleColumns.map((column) => `${column}: true`).join(", ")} })`
39
+ : "";
25
40
 
26
41
  return {
27
42
  ...this.getTargetAndPath(names),
@@ -34,9 +49,9 @@ export const ${entityId}ListParams = ${entityId}BaseListParams;
34
49
  export type ${entityId}ListParams = z.infer<typeof ${entityId}ListParams>;
35
50
 
36
51
  // ${entityId} - SaveParams
37
- export const ${entityId}SaveParams = ${entityId}BaseSchema.partial({ id: true${
38
- hasCreatedAt ? ", created_at: true" : ""
39
- } });
52
+ export const ${entityId}SaveParams = ${entityId}BaseSchema${omitGeneratedColumns}.partial({ ${partialColumns
53
+ .map((column) => `${column}: true`)
54
+ .join(", ")} });
40
55
  export type ${entityId}SaveParams = z.infer<typeof ${entityId}SaveParams>;
41
56
 
42
57
  `.trim(),
@@ -46,6 +46,7 @@ import {
46
46
  isNumericSingleProp,
47
47
  isOneToOneRelationProp,
48
48
  isRelationProp,
49
+ isSearchTextProp,
49
50
  isStringArrayProp,
50
51
  isStringSingleProp,
51
52
  isTsVectorProp,
@@ -248,6 +249,8 @@ export async function propToZodType(prop: EntityProp): Promise<z.ZodTypeAny> {
248
249
  zodType = z.uuid().array();
249
250
  } else if (isJsonProp(prop)) {
250
251
  zodType = await getZodTypeById(prop.id);
252
+ } else if (isSearchTextProp(prop)) {
253
+ zodType = z.string();
251
254
  } else if (isVectorSingleProp(prop)) {
252
255
  zodType = z.array(z.number());
253
256
  } else if (isVectorArrayProp(prop)) {
@@ -354,6 +357,8 @@ export function propToZodTypeDef(prop: EntityProp, injectImportKeys: string[]):
354
357
  stmt = `${prop.name}: ${prop.id}`;
355
358
  injectImportKeys.push(prop.id);
356
359
  }
360
+ } else if (isSearchTextProp(prop)) {
361
+ stmt = `${prop.name}: z.string()`;
357
362
  } else if (isVectorSingleProp(prop)) {
358
363
  stmt = `${prop.name}: z.array(z.number())`;
359
364
  } else if (isVectorArrayProp(prop)) {
@@ -200,10 +200,12 @@ export async function registerDevTestRoutes(
200
200
  };
201
201
  manager.addEventListener(listener);
202
202
 
203
- request.socket.on("close", () => {
203
+ const cleanup = () => {
204
204
  clearInterval(heartbeatTimer);
205
205
  manager.removeEventListener(listener);
206
- });
206
+ };
207
+ request.socket.on("close", cleanup);
208
+ request.socket.on("error", cleanup);
207
209
  });
208
210
  }
209
211
 
@@ -123,6 +123,16 @@ export class FixtureGenerator {
123
123
 
124
124
  // 1. Relation prop 처리
125
125
  if (isRelationProp(prop)) {
126
+ // BelongsToOne / OneToOne(hasJoinColumn)은 FK 컬럼명({prop.name}_id)으로도 override를 받는다
127
+ const fkColName = `${prop.name}_id`;
128
+ if (
129
+ fkColName in overrides &&
130
+ (isBelongsToOneRelationProp(prop) || (isOneToOneRelationProp(prop) && prop.hasJoinColumn))
131
+ ) {
132
+ fixture[fkColName] = overrides[fkColName];
133
+ continue;
134
+ }
135
+
126
136
  const relationValue = await this.generateRelationValue(entity, prop, context);
127
137
  // BelongsToOne, OneToOne(hasJoinColumn)의 경우 foreign key 컬럼명으로 저장
128
138
  if (
@@ -803,13 +813,16 @@ export class FixtureGenerator {
803
813
  const { createAnthropic } = await import("@ai-sdk/anthropic");
804
814
  const { generateText } = await import("ai");
805
815
 
806
- const { text } = await generateText({
816
+ const rowResponse = await generateText({
807
817
  model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
808
818
  prompt: this.buildRowLLMPrompt(llmProps, entity),
809
819
  });
820
+ if (!rowResponse || typeof rowResponse.text !== "string") {
821
+ throw new Error("Invalid LLM response");
822
+ }
810
823
 
811
824
  // 응답을 파싱하여 각 필드에 대한 결과를 캐시에 저장
812
- const rowResult = this.parseRowLLMResponse(text, llmProps);
825
+ const rowResult = this.parseRowLLMResponse(rowResponse.text, llmProps);
813
826
  for (const [fieldName, value] of Object.entries(rowResult)) {
814
827
  this.llmCache.set(`${rowKey}:${fieldName}`, value);
815
828
  }
@@ -844,12 +857,15 @@ export class FixtureGenerator {
844
857
  const { createAnthropic } = await import("@ai-sdk/anthropic");
845
858
  const { generateText } = await import("ai");
846
859
 
847
- const { text } = await generateText({
860
+ const singleResponse = await generateText({
848
861
  model: createAnthropic({ apiKey })(this.options.llmModel || "claude-sonnet-4-5"),
849
862
  prompt: this.buildLLMPrompt(fixtureHint, prop, entity),
850
863
  });
864
+ if (!singleResponse || typeof singleResponse.text !== "string") {
865
+ throw new Error("Invalid LLM response");
866
+ }
851
867
 
852
- const value = this.parseLLMResponse(text, prop.type);
868
+ const value = this.parseLLMResponse(singleResponse.text, prop.type);
853
869
  if (this.options.enableLLMCache) {
854
870
  this.llmCache.set(cacheKey, value);
855
871
  }
@@ -944,7 +960,13 @@ ${outputShape}
944
960
 
945
961
  let parsed: Record<string, unknown>;
946
962
  try {
947
- parsed = JSON.parse(jsonText);
963
+ const raw = JSON.parse(jsonText);
964
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
965
+ !isTest() &&
966
+ console.warn("[FixtureGenerator] Row LLM response is not a plain object:", text);
967
+ return {};
968
+ }
969
+ parsed = raw as Record<string, unknown>;
948
970
  } catch {
949
971
  !isTest() && console.warn("[FixtureGenerator] Failed to parse row LLM response:", text);
950
972
  return {};
@@ -1469,11 +1491,137 @@ Rules:
1469
1491
  // 3. targetDb에 삽입 (FixtureManager가 의존성 정렬 처리)
1470
1492
  const results = await FixtureManager.insertFixtures(this.targetDbName, fixtureRecords);
1471
1493
 
1494
+ // 4. companion fixtures 생성 (fixtureCompanions가 선언된 경우)
1495
+ const companionResults = await this.generateCompanions(specs, results);
1496
+
1497
+ const total = results.length + companionResults.length;
1472
1498
  !isTest() &&
1473
- console.log(
1474
- chalk.green(`Generated and saved ${results.length} fixtures to ${this.targetDbName}`),
1475
- );
1476
- return results;
1499
+ console.log(chalk.green(`Generated and saved ${total} fixtures to ${this.targetDbName}`));
1500
+ return [...results, ...companionResults];
1501
+ }
1502
+
1503
+ /**
1504
+ * 부모 fixture 결과를 기반으로 fixtureCompanions에 선언된 companion Entity를 생성합니다.
1505
+ *
1506
+ * generateBatch()에서만 호출되며, companion 생성 시 재귀를 방지하기 위해
1507
+ * generateBatch()를 다시 호출하지 않고 직접 삽입합니다.
1508
+ */
1509
+ private async generateCompanions(
1510
+ specs: Array<{ entity: string; count: number; overrides?: Record<string, unknown> }>,
1511
+ parentResults: FixtureImportResult[],
1512
+ ): Promise<FixtureImportResult[]> {
1513
+ const allResults: FixtureImportResult[] = [];
1514
+ const processedEntities = new Set<string>();
1515
+
1516
+ for (const spec of specs) {
1517
+ if (processedEntities.has(spec.entity)) continue;
1518
+ processedEntities.add(spec.entity);
1519
+
1520
+ const entity = this.entityManager.get(spec.entity);
1521
+ const idProp = entity.props.find((p) => p.name === "id");
1522
+ const companions = idProp?.cone?.fixtureCompanions;
1523
+ if (!companions || companions.length === 0) continue;
1524
+
1525
+ const entityResults = parentResults.filter((r) => r.entityId === spec.entity);
1526
+ if (entityResults.length === 0) continue;
1527
+
1528
+ for (const companion of companions) {
1529
+ // companion entity에서 부모 entity로의 BelongsToOne FK 컬럼명 파악
1530
+ const companionEntity = this.entityManager.get(companion.entity);
1531
+ const fkProp = companionEntity.props.find(
1532
+ (p) => isRelationProp(p) && isBelongsToOneRelationProp(p) && p.with === spec.entity,
1533
+ );
1534
+ if (!fkProp) {
1535
+ !isTest() &&
1536
+ console.warn(
1537
+ chalk.yellow(
1538
+ `[Companion] No BelongsToOne relation from ${companion.entity} to ${spec.entity}. Skipping.`,
1539
+ ),
1540
+ );
1541
+ continue;
1542
+ }
1543
+ const fkColName = `${fkProp.name}_id`;
1544
+
1545
+ // companion의 idProp, usesSequence, count는 companion 단위로 고정
1546
+ const companionIdProp = companionEntity.props.find((p) => p.name === "id");
1547
+ const usesSequence =
1548
+ companionIdProp?.type === "integer" ||
1549
+ companionIdProp?.type === "bigInteger" ||
1550
+ companionIdProp?.cone?.fixtureStrategy === "sequence";
1551
+ const companionCount = companion.count ?? 1;
1552
+
1553
+ // 각 parent result에 대해 companion fixture 생성
1554
+ const context = this.createContext();
1555
+ const companionFixtureRecords: FixtureRecord[] = [];
1556
+
1557
+ for (const parentResult of entityResults) {
1558
+ const resolvedOverrides = this.resolveTemplateOverrides(
1559
+ companion.overrides ?? {},
1560
+ parentResult.data,
1561
+ );
1562
+ resolvedOverrides[fkColName] = parentResult.data.id;
1563
+
1564
+ for (let i = 0; i < companionCount; i++) {
1565
+ const fixture = await this.generate(companion.entity, resolvedOverrides, context);
1566
+
1567
+ const dataForRecord = usesSequence
1568
+ ? { ...fixture, id: Math.floor(Math.random() * 1000000) }
1569
+ : fixture;
1570
+
1571
+ const records = await FixtureManager.createFixtureRecord(
1572
+ companionEntity,
1573
+ dataForRecord as {
1574
+ id: number | string;
1575
+ [key: string]: string | number | boolean | null;
1576
+ },
1577
+ { singleRecord: true },
1578
+ );
1579
+ companionFixtureRecords.push(...records);
1580
+ }
1581
+ }
1582
+
1583
+ const companionResults = await FixtureManager.insertFixtures(
1584
+ this.targetDbName,
1585
+ companionFixtureRecords,
1586
+ );
1587
+ allResults.push(...companionResults);
1588
+
1589
+ !isTest() &&
1590
+ console.log(
1591
+ chalk.green(
1592
+ `[Companion] Generated ${companionResults.length} ${companion.entity} fixtures`,
1593
+ ),
1594
+ );
1595
+ }
1596
+ }
1597
+
1598
+ return allResults;
1599
+ }
1600
+
1601
+ /**
1602
+ * overrides 값의 "{{fieldName}}" 템플릿을 부모 fixture 데이터로 치환합니다.
1603
+ *
1604
+ * 예: { "account_id": "{{email}}" } → { "account_id": "user@example.com" }
1605
+ */
1606
+ private resolveTemplateOverrides(
1607
+ overrides: Record<string, unknown>,
1608
+ parentData: { [key: string]: string | number | boolean | Date | null },
1609
+ ): Record<string, unknown> {
1610
+ const resolved: Record<string, unknown> = {};
1611
+ for (const [key, value] of Object.entries(overrides)) {
1612
+ if (typeof value === "string" && value.startsWith("{{") && value.endsWith("}}")) {
1613
+ const fieldName = value.slice(2, -2).trim();
1614
+ if (!(fieldName in parentData)) {
1615
+ throw new Error(
1616
+ `템플릿 필드 "${fieldName}"이(가) 부모 fixture 데이터에 존재하지 않습니다 (override key: "${key}")`,
1617
+ );
1618
+ }
1619
+ resolved[key] = parentData[fieldName];
1620
+ } else {
1621
+ resolved[key] = value;
1622
+ }
1623
+ }
1624
+ return resolved;
1477
1625
  }
1478
1626
 
1479
1627
  /**
@@ -495,7 +495,21 @@ export class FixtureManagerClass {
495
495
  this.fixtureRefMap = new Map();
496
496
  this.skippedFixtures = new Map();
497
497
 
498
- const db = createKnexInstance(Sonamu.dbConfig[dbName]);
498
+ // 병렬 테스트 모드에서는 worker별 DB에 저장
499
+ const dbConfig =
500
+ process.env.SONAMU_WORKER_DB === "true" && process.env.VITEST_POOL_ID
501
+ ? (() => {
502
+ const workerId = parseInt(process.env.VITEST_POOL_ID ?? "1", 10);
503
+ const baseConfig = Sonamu.dbConfig[dbName];
504
+ const connection = baseConfig.connection as { database: string };
505
+ return {
506
+ ...baseConfig,
507
+ connection: { ...connection, database: `${connection.database}_${workerId}` },
508
+ pool: { min: 1, max: 1 },
509
+ };
510
+ })()
511
+ : Sonamu.dbConfig[dbName];
512
+ const db = createKnexInstance(dbConfig);
499
513
  const results: FixtureImportResult[] = [];
500
514
 
501
515
  try {
@@ -0,0 +1,179 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { EntityJsonSchema, isSearchTextJsonSourceZodType, TemplateOptions } from "../types";
4
+
5
+ function createBaseEntity() {
6
+ return {
7
+ id: "SearchTextTest",
8
+ title: "SearchText Test",
9
+ table: "search_text_tests",
10
+ props: [
11
+ { name: "id", type: "integer" },
12
+ { name: "title", type: "string" },
13
+ { name: "tags", type: "string[]" },
14
+ { name: "aliases", type: "json", id: "StringArray" },
15
+ {
16
+ name: "search_text",
17
+ type: "searchText",
18
+ sourceColumns: [{ name: "title", caseInsensitive: true }],
19
+ },
20
+ ],
21
+ indexes: [
22
+ {
23
+ type: "index",
24
+ name: "search_text_tests_search_text_index",
25
+ using: "gin",
26
+ columns: [{ name: "search_text", opclass: "gin_trgm_ops" }],
27
+ },
28
+ ],
29
+ subsets: {
30
+ A: ["id", "title", "tags", "aliases", "search_text"],
31
+ },
32
+ enums: {},
33
+ } as const;
34
+ }
35
+
36
+ describe("EntityJsonSchema searchText/opclass validation", () => {
37
+ test("opclass known/free string과 vectorOps 하위호환을 허용해야 한다", () => {
38
+ const knownOpclass = createBaseEntity();
39
+ const knownResult = EntityJsonSchema.safeParse(knownOpclass);
40
+ expect(knownResult.success).toBe(true);
41
+
42
+ const freeOpclass = createBaseEntity();
43
+ freeOpclass.indexes[0] = {
44
+ ...freeOpclass.indexes[0],
45
+ columns: [{ name: "search_text", opclass: "custom_text_ops" }],
46
+ };
47
+ const freeResult = EntityJsonSchema.safeParse(freeOpclass);
48
+ expect(freeResult.success).toBe(true);
49
+
50
+ const legacyVectorOps = createBaseEntity();
51
+ legacyVectorOps.indexes[0] = {
52
+ type: "hnsw",
53
+ name: "search_text_tests_embedding_hnsw",
54
+ columns: [{ name: "embedding", vectorOps: "vector_cosine_ops" }],
55
+ m: 16,
56
+ efConstruction: 64,
57
+ };
58
+ const legacyResult = EntityJsonSchema.safeParse(legacyVectorOps);
59
+ expect(legacyResult.success).toBe(true);
60
+ });
61
+
62
+ test("searchText source column 존재/타입 검증이 동작해야 한다", () => {
63
+ const unknownSource = createBaseEntity();
64
+ unknownSource.props[4] = {
65
+ name: "search_text",
66
+ type: "searchText",
67
+ sourceColumns: [{ name: "missing_col", caseInsensitive: true }],
68
+ };
69
+ const unknownResult = EntityJsonSchema.safeParse(unknownSource);
70
+ expect(unknownResult.success).toBe(false);
71
+
72
+ const unsupportedSourceType = createBaseEntity();
73
+ unsupportedSourceType.props[4] = {
74
+ name: "search_text",
75
+ type: "searchText",
76
+ sourceColumns: [{ name: "id", caseInsensitive: true }],
77
+ };
78
+ const unsupportedResult = EntityJsonSchema.safeParse(unsupportedSourceType);
79
+ expect(unsupportedResult.success).toBe(false);
80
+ });
81
+
82
+ test("schema 단계에서는 searchText json source id naming을 강제하지 않아야 한다", () => {
83
+ const nullableWrapper = createBaseEntity();
84
+ nullableWrapper.props[3] = { name: "aliases", type: "json", id: "NullableStringArray" };
85
+ nullableWrapper.props[4] = {
86
+ name: "search_text",
87
+ type: "searchText",
88
+ sourceColumns: [{ name: "aliases", caseInsensitive: true }],
89
+ };
90
+ const wrapperResult = EntityJsonSchema.safeParse(nullableWrapper);
91
+ expect(wrapperResult.success).toBe(true);
92
+
93
+ const nullableElement = createBaseEntity();
94
+ nullableElement.props[3] = { name: "aliases", type: "json", id: "StringNullableArray" };
95
+ nullableElement.props[4] = {
96
+ name: "search_text",
97
+ type: "searchText",
98
+ sourceColumns: [{ name: "aliases", caseInsensitive: true }],
99
+ };
100
+ const nullableElementResult = EntityJsonSchema.safeParse(nullableElement);
101
+ expect(nullableElementResult.success).toBe(true);
102
+
103
+ const customNamedType = createBaseEntity();
104
+ customNamedType.props[3] = { name: "aliases", type: "json", id: "CustomAliasType" };
105
+ customNamedType.props[4] = {
106
+ name: "search_text",
107
+ type: "searchText",
108
+ sourceColumns: [{ name: "aliases", caseInsensitive: true }],
109
+ };
110
+ const customNamedTypeResult = EntityJsonSchema.safeParse(customNamedType);
111
+ expect(customNamedTypeResult.success).toBe(true);
112
+ });
113
+
114
+ test("TemplateOptions.entity 경로도 searchText source 검증을 동일하게 적용해야 한다", () => {
115
+ const validEntity = createBaseEntity();
116
+ const validTemplateEntity = {
117
+ entityId: validEntity.id,
118
+ title: validEntity.title,
119
+ table: validEntity.table,
120
+ props: validEntity.props,
121
+ indexes: validEntity.indexes,
122
+ subsets: validEntity.subsets,
123
+ enums: validEntity.enums,
124
+ };
125
+ const validResult = TemplateOptions.shape.entity.safeParse(validTemplateEntity);
126
+ expect(validResult.success).toBe(true);
127
+
128
+ const invalidEntity = createBaseEntity();
129
+ invalidEntity.props[4] = {
130
+ name: "search_text",
131
+ type: "searchText",
132
+ sourceColumns: [{ name: "missing_col", caseInsensitive: true }],
133
+ };
134
+ const invalidTemplateEntity = {
135
+ entityId: invalidEntity.id,
136
+ title: invalidEntity.title,
137
+ table: invalidEntity.table,
138
+ props: invalidEntity.props,
139
+ indexes: invalidEntity.indexes,
140
+ subsets: invalidEntity.subsets,
141
+ enums: invalidEntity.enums,
142
+ };
143
+ const invalidResult = TemplateOptions.shape.entity.safeParse(invalidTemplateEntity);
144
+ expect(invalidResult.success).toBe(false);
145
+ });
146
+
147
+ test("TemplateOptions.entity 경로도 json id naming에 의존하지 않아야 한다", () => {
148
+ const customNamedType = createBaseEntity();
149
+ customNamedType.props[3] = { name: "aliases", type: "json", id: "CustomAliasType" };
150
+ customNamedType.props[4] = {
151
+ name: "search_text",
152
+ type: "searchText",
153
+ sourceColumns: [{ name: "aliases", caseInsensitive: true }],
154
+ };
155
+
156
+ const customTemplateEntity = {
157
+ entityId: customNamedType.id,
158
+ title: customNamedType.title,
159
+ table: customNamedType.table,
160
+ props: customNamedType.props,
161
+ indexes: customNamedType.indexes,
162
+ subsets: customNamedType.subsets,
163
+ enums: customNamedType.enums,
164
+ };
165
+ const customTemplateResult = TemplateOptions.shape.entity.safeParse(customTemplateEntity);
166
+ expect(customTemplateResult.success).toBe(true);
167
+ });
168
+
169
+ test("searchText json source Zod 구조 검증 유틸이 wrapper/element 타입을 정확히 판정해야 한다", () => {
170
+ expect(isSearchTextJsonSourceZodType(z.array(z.string()))).toBe(true);
171
+ expect(isSearchTextJsonSourceZodType(z.array(z.string()).optional())).toBe(true);
172
+ expect(isSearchTextJsonSourceZodType(z.array(z.string()).nullable())).toBe(true);
173
+ expect(isSearchTextJsonSourceZodType(z.array(z.string()).nullish())).toBe(true);
174
+
175
+ expect(isSearchTextJsonSourceZodType(z.array(z.string().nullable()))).toBe(false);
176
+ expect(isSearchTextJsonSourceZodType(z.array(z.number()))).toBe(false);
177
+ expect(isSearchTextJsonSourceZodType(z.string())).toBe(false);
178
+ });
179
+ });