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
@@ -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-CQ_S40bD.js"></script>
9
- <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-egkMxKos.css">
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-D_19-Pi4.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-D4XFBV-f.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.8.13",
3
+ "version": "0.8.14",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "typescript",
@@ -54,6 +54,10 @@
54
54
  "./filter": {
55
55
  "import": "./dist/filter/index.js",
56
56
  "types": "./dist/filter/index.d.ts"
57
+ },
58
+ "./cdd-types": {
59
+ "import": "./dist/ui/cdd-types.js",
60
+ "types": "./dist/ui/cdd-types.d.ts"
57
61
  }
58
62
  },
59
63
  "license": "MIT",
@@ -121,8 +125,8 @@
121
125
  "vitest": "^4.0.10",
122
126
  "@sonamu-kit/hmr-hook": "^0.4.1",
123
127
  "@sonamu-kit/hmr-runner": "^0.1.1",
124
- "@sonamu-kit/ts-loader": "^2.1.3",
125
- "@sonamu-kit/tasks": "^0.2.0"
128
+ "@sonamu-kit/tasks": "^0.2.0",
129
+ "@sonamu-kit/ts-loader": "^2.1.3"
126
130
  },
127
131
  "devDependencies": {
128
132
  "@biomejs/biome": "^2.3.13",
package/src/api/sonamu.ts CHANGED
@@ -495,8 +495,7 @@ class SonamuClass {
495
495
  if (handler) {
496
496
  return handler(request, reply);
497
497
  }
498
- // 사실 /api로 시작하지 않는 요청은 여기에 들어오지도 않을 거라 이 라인은 도달 불가능입니다만,
499
- // 안전빵으로 남겨놓습니다.
498
+ // 등록된 API와 일치하지 않는 요청에 대한 fallback입니다.
500
499
  throw new NotFoundException(SD("error.api.notFound"));
501
500
  },
502
501
  });
@@ -200,6 +200,44 @@ export interface GenerateBetterAuthEntitiesOptions {
200
200
  plugins?: BetterAuthPluginId[];
201
201
  }
202
202
 
203
+ /**
204
+ * 기존 프로젝트의 entity.json에 fixtureCompanions를 소급 추가합니다.
205
+ *
206
+ * betterAuthV1 기준으로 fixtureCompanions가 정의된 entity를 찾아
207
+ * 프로젝트 내 entity.json에 해당 prop의 cone에 fixtureCompanions가
208
+ * 없을 때만 추가합니다. 이미 있으면 스킵합니다.
209
+ */
210
+ export async function addCompanionsToEntities(): Promise<void> {
211
+ for (const entityJson of betterAuthV1) {
212
+ const idProp = entityJson.props?.find((p) => p.name === "id");
213
+ if (!idProp?.cone?.fixtureCompanions) continue;
214
+
215
+ if (!EntityManager.exists(entityJson.id)) {
216
+ console.log(chalk.yellow(`[SKIP] ${entityJson.id} - not found`));
217
+ continue;
218
+ }
219
+
220
+ const entity = EntityManager.get(entityJson.id);
221
+ const existingIdProp = entity.props.find((p) => p.name === "id");
222
+ if (!existingIdProp) {
223
+ console.log(chalk.yellow(`[SKIP] ${entityJson.id}.id - prop not found`));
224
+ continue;
225
+ }
226
+
227
+ if (existingIdProp.cone?.fixtureCompanions) {
228
+ console.log(chalk.dim(`[SKIP] ${entityJson.id}.id - fixtureCompanions already exists`));
229
+ continue;
230
+ }
231
+
232
+ existingIdProp.cone = {
233
+ ...existingIdProp.cone,
234
+ fixtureCompanions: idProp.cone.fixtureCompanions,
235
+ };
236
+ await entity.save();
237
+ console.log(chalk.green(`[UPDATED] ${entityJson.id}.id - fixtureCompanions added`));
238
+ }
239
+ }
240
+
203
241
  /**
204
242
  * better-auth 엔티티들을 Sonamu에 생성/업데이트
205
243
  *
@@ -16,7 +16,24 @@ export const betterAuthV1: EntityJson[] = [
16
16
  table: "users",
17
17
  title: "사용자",
18
18
  props: [
19
- { name: "id", type: "string", desc: "ID", cone: { fixtureStrategy: "sequence" } },
19
+ {
20
+ name: "id",
21
+ type: "string",
22
+ desc: "ID",
23
+ cone: {
24
+ fixtureStrategy: "sequence",
25
+ fixtureCompanions: [
26
+ {
27
+ entity: "Account",
28
+ overrides: {
29
+ provider_id: "credential",
30
+ account_id: "{{email}}",
31
+ password: "{{email}}",
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ },
20
37
  { name: "name", type: "string", desc: "이름" },
21
38
  { name: "email", type: "string", desc: "이메일" },
22
39
  { name: "email_verified", type: "boolean", desc: "이메일 인증 여부" },
package/src/bin/cli.ts CHANGED
@@ -13,7 +13,7 @@ import path from "path";
13
13
  import process from "process";
14
14
  import { tsicli } from "tsicli";
15
15
  import { Sonamu } from "../api";
16
- import { generateBetterAuthEntities } from "../auth/auth-generator";
16
+ import { addCompanionsToEntities, generateBetterAuthEntities } from "../auth/auth-generator";
17
17
  import {
18
18
  type BetterAuthPluginId,
19
19
  isValidPluginId,
@@ -179,6 +179,7 @@ async function bootstrap() {
179
179
  ["skills", "create", "#name"],
180
180
  ["test"],
181
181
  ["auth", "generate"],
182
+ ["auth", "add-companions"],
182
183
  ],
183
184
  runners: {
184
185
  migrate_status,
@@ -209,6 +210,7 @@ async function bootstrap() {
209
210
  skills_create,
210
211
  test: testCommand,
211
212
  auth_generate,
213
+ "auth_add-companions": auth_add_companions,
212
214
  },
213
215
  });
214
216
  } finally {
@@ -1275,6 +1277,18 @@ async function auth_generate() {
1275
1277
  await generateBetterAuthEntities({ plugins });
1276
1278
  }
1277
1279
 
1280
+ /**
1281
+ * pnpm sonamu auth add-companions 하면 실행되는 함수입니다.
1282
+ * 기존 프로젝트의 entity.json에 fixtureCompanions를 소급 추가합니다.
1283
+ *
1284
+ * 이미 fixtureCompanions가 있는 entity는 스킵합니다 (덮어쓰기 없음).
1285
+ */
1286
+ async function auth_add_companions() {
1287
+ console.log(chalk.yellow.bold("🔐 Adding fixtureCompanions to better-auth entities...\n"));
1288
+ await addCompanionsToEntities();
1289
+ console.log(chalk.bold("\n✅ Done!"));
1290
+ }
1291
+
1278
1292
  /**
1279
1293
  * 워크스페이스 루트를 찾습니다.
1280
1294
  * 우선순위: pnpm-workspace.yaml > CLAUDE.md > 루트 package.json (workspaces 필드)
@@ -200,9 +200,15 @@ INSTRUCTIONS:
200
200
  The fixture generator will pass all such props together to LLM in a single call to ensure consistency.
201
201
  Detection rule: if a prop name matches another prop name with a locale suffix (_en, _ko, _ja, _cn) or vice versa, treat them as correlated.
202
202
 
203
- 10. String PK with DB sequence:
204
- If a prop named "id" has type "string" and the entity uses a DB sequence for id generation (indicated by dbDefault containing "nextval" or by the entity being a user/auth entity managed externally), set fixtureStrategy: "sequence" and do NOT set fixtureGenerator.
205
- note should mention that the id is managed by DB sequence as a sequential number stored as string.
203
+ 10. String PK sequence vs UUID:
204
+ - DB sequence id: If a prop named "id" has type "string" and uses a DB sequence (indicated by dbDefault containing "nextval"), set fixtureStrategy: "sequence" and do NOT set fixtureGenerator. note should mention sequential number stored as string.
205
+ - better-auth entity id: Account, Session, Verification 엔티티의 id better-auth가 crypto.randomUUID()로 생성하는 UUID다. fixtureStrategy: "sequence"를 절대 사용하지 말고, fixtureGenerator: "faker.string.uuid()"를 사용한다.
206
+
207
+ 11. fixtureCompanions (IMPORTANT - never generate or modify):
208
+ - fixtureCompanions is user-declared metadata that triggers automatic companion fixture creation when a parent fixture is generated.
209
+ - Do NOT generate or suggest fixtureCompanions for any prop. Only users declare this intentionally.
210
+ - If a prop's existing cone already contains fixtureCompanions, preserve it exactly as-is in the propCones output. Do not remove, alter, or omit it.
211
+ - Example: if User entity's "id" prop cone has fixtureCompanions, include it unchanged in propCones["id"].
206
212
 
207
213
  ${
208
214
  context.existingCones
@@ -1,7 +1,52 @@
1
1
  import type { Knex } from "knex";
2
2
  import knex from "knex";
3
3
 
4
+ /**
5
+ * connection 객체를 libpq 연결 문자열로 변환합니다.
6
+ * pg-native는 libpq를 사용하므로, keepalive 등 libpq 파라미터를
7
+ * 연결 문자열로 전달해야 합니다.
8
+ */
9
+ function buildLibpqConnectionString(conn: Record<string, unknown>): string {
10
+ const mapping: Array<[string, string]> = [
11
+ ["host", "host"],
12
+ ["port", "port"],
13
+ ["user", "user"],
14
+ ["password", "password"],
15
+ ["database", "dbname"],
16
+ ];
17
+
18
+ const parts: string[] = [];
19
+ for (const [jsKey, pqKey] of mapping) {
20
+ if (conn[jsKey] != null) {
21
+ parts.push(`${pqKey}='${String(conn[jsKey]).replace(/'/g, "\\'")}'`);
22
+ }
23
+ }
24
+
25
+ // TCP keepAlive (libpq parameters)
26
+ parts.push("keepalives=1");
27
+ parts.push("keepalives_idle=10");
28
+ parts.push("keepalives_interval=10");
29
+ parts.push("keepalives_count=5");
30
+
31
+ return parts.join(" ");
32
+ }
33
+
4
34
  export function createKnexInstance(config: Knex.Config): Knex {
35
+ if (config.connection && typeof config.connection === "object") {
36
+ const conn = config.connection as Record<string, unknown>;
37
+
38
+ if (config.client === "pgnative" || config.client === "pg-native") {
39
+ // pg-native: libpq 연결 문자열로 변환하여 keepalive 파라미터 포함
40
+ config.connection = buildLibpqConnectionString(conn);
41
+ } else {
42
+ // pg: keepAlive 설정 (Node.js TCP socket level)
43
+ if (conn.keepAlive === undefined) {
44
+ conn.keepAlive = true;
45
+ conn.keepAliveInitialDelayMillis = conn.keepAliveInitialDelayMillis ?? 10000;
46
+ }
47
+ }
48
+ }
49
+
5
50
  config.pool = {
6
51
  ...(config.pool ?? {}),
7
52
  propagateCreateError: false,
@@ -9,7 +54,18 @@ export function createKnexInstance(config: Knex.Config): Knex {
9
54
  reapIntervalMillis: 1000,
10
55
  acquireTimeoutMillis: 30000,
11
56
  createTimeoutMillis: 30000,
12
- afterCreate: ((conn: Knex.Client, done: (err: Error | null, conn: Knex.Client) => void) => {
57
+ afterCreate: ((
58
+ conn: Knex.Client & Record<string, unknown>,
59
+ done: (err: Error | null, conn: Knex.Client) => void,
60
+ ) => {
61
+ // pg driver: 소켓 레벨 keepAlive 설정
62
+ const stream = (conn as Record<string, unknown>).connection as
63
+ | { stream?: { setKeepAlive?: (enable: boolean, initialDelay: number) => void } }
64
+ | undefined;
65
+ if (stream?.stream?.setKeepAlive) {
66
+ stream.stream.setKeepAlive(true, 10000);
67
+ }
68
+
13
69
  conn.on("error", (err: Error) => {
14
70
  Object.defineProperty(conn, "__knex__disposed", {
15
71
  value: err,
@@ -25,9 +81,11 @@ export function createKnexInstance(config: Knex.Config): Knex {
25
81
 
26
82
  const knexInstance = knex(config);
27
83
  knexInstance.client.validateConnection = (connection: unknown) => {
28
- return (
29
- typeof connection === "object" && connection !== null && !("__knex__disposed" in connection)
30
- );
84
+ if (typeof connection !== "object" || connection === null) return false;
85
+ if ("__knex__disposed" in connection) return false;
86
+ const conn = connection as Record<string, unknown>;
87
+ if (conn._ending === true || conn._closed === true) return false;
88
+ return true;
31
89
  };
32
90
 
33
91
  return knexInstance;
@@ -14,6 +14,7 @@ import type {
14
14
  Expand,
15
15
  ExtractColumnType,
16
16
  FulltextColumns,
17
+ FuzzyOperator,
17
18
  InsertData,
18
19
  InsertResult,
19
20
  LeftJoinedMarker,
@@ -34,8 +35,20 @@ import type {
34
35
  WhereCondition,
35
36
  WhereOperator,
36
37
  } from "./puri.types";
38
+ import { FUZZY_OPERATORS } from "./puri.types";
37
39
  import type { ClearStatements } from "./puri-subset.types";
38
40
 
41
+ function normalizeFuzzyOperator(operator?: string): FuzzyOperator {
42
+ const normalized = operator?.trim() ?? "<%";
43
+ const fuzzyOperator = FUZZY_OPERATORS.find((candidate) => candidate === normalized);
44
+
45
+ if (!fuzzyOperator) {
46
+ throw new Error(`Invalid fuzzy operator: ${operator ?? ""}`);
47
+ }
48
+
49
+ return fuzzyOperator;
50
+ }
51
+
39
52
  export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
40
53
  private knexQuery: Knex.QueryBuilder;
41
54
  private tableSpec: TableSpec | null = null;
@@ -138,6 +151,45 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
138
151
  };
139
152
  }
140
153
 
154
+ private static escapeSqlLiteral(value: string): string {
155
+ return value.replaceAll("'", "''");
156
+ }
157
+
158
+ private static toTextOperand(column: string | SqlExpression<"string">): string {
159
+ if (typeof column === "object" && column._type === "sql_expression") {
160
+ return `(${column._sql})::text`;
161
+ }
162
+
163
+ return `${column}::text`;
164
+ }
165
+
166
+ static wordSimilarity(
167
+ column: string | SqlExpression<"string">,
168
+ query: string,
169
+ ): SqlExpression<"number"> {
170
+ return Puri.rawNumber(
171
+ `word_similarity('${Puri.escapeSqlLiteral(query)}'::text, ${Puri.toTextOperand(column)})`,
172
+ );
173
+ }
174
+
175
+ static similarity(
176
+ column: string | SqlExpression<"string">,
177
+ query: string,
178
+ ): SqlExpression<"number"> {
179
+ return Puri.rawNumber(
180
+ `similarity(${Puri.toTextOperand(column)}, '${Puri.escapeSqlLiteral(query)}'::text)`,
181
+ );
182
+ }
183
+
184
+ static strictWordSimilarity(
185
+ column: string | SqlExpression<"string">,
186
+ query: string,
187
+ ): SqlExpression<"number"> {
188
+ return Puri.rawNumber(
189
+ `strict_word_similarity('${Puri.escapeSqlLiteral(query)}'::text, ${Puri.toTextOperand(column)})`,
190
+ );
191
+ }
192
+
141
193
  // Raw functions for SELECT
142
194
  static rawString(sql: string): SqlExpression<"string"> {
143
195
  return { _type: "sql_expression", _return: "string", _sql: sql };
@@ -651,6 +703,25 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
651
703
  return this;
652
704
  }
653
705
 
706
+ whereFuzzy<TColumn extends AvailableColumns<TTables> | SqlExpression<"string">>(
707
+ column: TColumn,
708
+ value: string,
709
+ options?: {
710
+ operator?: FuzzyOperator;
711
+ },
712
+ ): this {
713
+ const operator = normalizeFuzzyOperator(options?.operator);
714
+ const textColumnExpr = Puri.toTextOperand(column);
715
+
716
+ if (operator === "%") {
717
+ this.knexQuery.whereRaw(`${textColumnExpr} ${operator} ?::text`, [value]);
718
+ return this;
719
+ }
720
+
721
+ this.knexQuery.whereRaw(`?::text ${operator} ${textColumnExpr}`, [value]);
722
+ return this;
723
+ }
724
+
654
725
  // WHERE RAW
655
726
  whereRaw(sql: string, bindings?: readonly unknown[]): this {
656
727
  this.knexQuery.whereRaw(sql, bindings);
@@ -301,6 +301,8 @@ export type FulltextColumns<TTables extends Record<string, any>> = {
301
301
  export type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "<>" | "!=";
302
302
  // 조건 연산자: 비교 연산자 + 패턴 매칭 연산자
303
303
  export type WhereOperator = ComparisonOperator | "like" | "not like" | "ilike" | "not ilike";
304
+ export const FUZZY_OPERATORS = ["<%", "%", "<<%"] as const;
305
+ export type FuzzyOperator = (typeof FUZZY_OPERATORS)[number];
304
306
 
305
307
  // SQL Expression 타입 정의
306
308
  export type SqlExpression<T extends "string" | "number" | "boolean" | "date" | "string[]"> = {
@@ -5,7 +5,15 @@ import inflection from "inflection";
5
5
  import path from "path";
6
6
  import { prettifyError, z } from "zod";
7
7
  import { Sonamu } from "../api/sonamu";
8
- import { type EntityIndex, type EntityJson, EntityJsonSchema } from "../types/types";
8
+ import {
9
+ type EntityIndex,
10
+ type EntityJson,
11
+ EntityJsonSchema,
12
+ isSearchTextJsonSourceZodType,
13
+ isSearchTextProp,
14
+ SonamuFileArraySchema,
15
+ SonamuFileSchema,
16
+ } from "../types/types";
9
17
  import { globAsync } from "../utils/async-utils";
10
18
  import { importMembers } from "../utils/esm-utils";
11
19
  import type { AbsolutePath } from "../utils/path-utils";
@@ -47,10 +55,11 @@ class EntityManagerClass {
47
55
  );
48
56
  }
49
57
 
50
- await this.register(json);
58
+ await this.register(json, { deferSearchTextJsonSourceValidation: true });
51
59
  }
52
60
 
53
61
  await this.registerNonEntityTypeModulePaths();
62
+ await this.validateAllRegisteredSearchTextJsonSources();
54
63
 
55
64
  this.isAutoloaded = true;
56
65
  }
@@ -69,13 +78,96 @@ class EntityManagerClass {
69
78
  return await this.autoload(doSilent);
70
79
  }
71
80
 
72
- async register(json: EntityJson): Promise<void> {
81
+ async register(
82
+ json: EntityJson,
83
+ options: { deferSearchTextJsonSourceValidation?: boolean } = {},
84
+ ): Promise<void> {
73
85
  const entity = new Entity(json);
74
86
  await entity.registerModulePaths();
87
+ if (!options.deferSearchTextJsonSourceValidation) {
88
+ await this.validateSearchTextJsonSources(entity);
89
+ }
75
90
  entity.registerTableSpecs();
76
91
  this.entities.set(json.id, entity);
77
92
  }
78
93
 
94
+ async validateAllRegisteredSearchTextJsonSources(): Promise<void> {
95
+ for (const entity of this.entities.values()) {
96
+ await this.validateSearchTextJsonSources(entity);
97
+ }
98
+ }
99
+
100
+ private async validateSearchTextJsonSources(entity: Entity): Promise<void> {
101
+ const propsByName = new Map(entity.props.map((prop) => [prop.name, prop]));
102
+
103
+ for (const prop of entity.props) {
104
+ if (!isSearchTextProp(prop)) {
105
+ continue;
106
+ }
107
+
108
+ for (const source of prop.sourceColumns) {
109
+ const sourceProp = propsByName.get(source.name);
110
+ if (!sourceProp || sourceProp.type !== "json") {
111
+ continue;
112
+ }
113
+
114
+ const zodType = await this.resolveSearchTextJsonSourceType(entity, sourceProp.id);
115
+ if (!zodType) {
116
+ throw new Error(
117
+ `searchText source "${source.name}"의 json 타입 "${sourceProp.id}"을(를) 로드할 수 없습니다.`,
118
+ );
119
+ }
120
+
121
+ if (!isSearchTextJsonSourceZodType(zodType)) {
122
+ throw new Error(
123
+ `searchText source "${source.name}"의 json 타입 "${sourceProp.id}"은(는) unwrap 후 z.array(z.string()) 이어야 합니다.`,
124
+ );
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ private async resolveSearchTextJsonSourceType(
131
+ entity: Entity,
132
+ typeId: string,
133
+ ): Promise<z.ZodTypeAny | null> {
134
+ const localType = entity.types[typeId];
135
+ if (localType instanceof z.ZodType) {
136
+ return localType;
137
+ }
138
+
139
+ for (const registeredEntity of this.entities.values()) {
140
+ const registeredType = registeredEntity.types[typeId];
141
+ if (registeredType instanceof z.ZodType) {
142
+ return registeredType;
143
+ }
144
+ }
145
+
146
+ if (typeId === "SonamuFile") {
147
+ return SonamuFileSchema;
148
+ }
149
+ if (typeId === "SonamuFile[]") {
150
+ return SonamuFileArraySchema;
151
+ }
152
+
153
+ const modulePath = this.modulePaths.get(typeId);
154
+ if (!modulePath) {
155
+ return null;
156
+ }
157
+
158
+ const moduleFilePath = path.join(
159
+ Sonamu.apiRootPath,
160
+ runtimePath(`dist/application/${modulePath}.js`),
161
+ );
162
+ const importedMembers = await importMembers<unknown>(moduleFilePath);
163
+ const matched = importedMembers.find(({ name }) => name === typeId);
164
+ if (!matched || !(matched.value instanceof z.ZodType)) {
165
+ return null;
166
+ }
167
+
168
+ return matched.value;
169
+ }
170
+
79
171
  get(entityId: string): Entity {
80
172
  const entity = this.entities.get(entityId);
81
173
  if (entity === undefined) {