sonamu 0.10.13 → 0.11.0

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 (230) hide show
  1. package/dist/ai/agents/agent.d.ts.map +1 -1
  2. package/dist/ai/providers/rtzr/error.d.ts.map +1 -1
  3. package/dist/ai/providers/rtzr/model.d.ts.map +1 -1
  4. package/dist/ai/providers/rtzr/utils.d.ts.map +1 -1
  5. package/dist/api/base-frame.d.ts.map +1 -1
  6. package/dist/api/decorators.d.ts.map +1 -1
  7. package/dist/api/sonamu.d.ts.map +1 -1
  8. package/dist/api/sonamu.js +3 -2
  9. package/dist/api/validator.d.ts.map +1 -1
  10. package/dist/auth/audit-log/events.d.ts.map +1 -1
  11. package/dist/auth/index.d.ts +1 -1
  12. package/dist/auth/index.d.ts.map +1 -1
  13. package/dist/auth/index.js +3 -3
  14. package/dist/auth/knex-adapter.d.ts.map +1 -1
  15. package/dist/auth/plugins/wrappers/admin.d.ts +4 -4
  16. package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
  17. package/dist/auth/plugins/wrappers/anonymous.d.ts.map +1 -1
  18. package/dist/auth/plugins/wrappers/api-key.d.ts.map +1 -1
  19. package/dist/auth/plugins/wrappers/jwt.d.ts.map +1 -1
  20. package/dist/auth/plugins/wrappers/organization.d.ts.map +1 -1
  21. package/dist/auth/plugins/wrappers/passkey.d.ts.map +1 -1
  22. package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
  23. package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -1
  24. package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
  25. package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
  26. package/dist/bin/build-config.d.ts +10 -0
  27. package/dist/bin/build-config.d.ts.map +1 -1
  28. package/dist/bin/build-config.js +130 -6
  29. package/dist/bin/fixture.d.ts +7 -4
  30. package/dist/bin/fixture.d.ts.map +1 -1
  31. package/dist/bin/fixture.js +161 -13
  32. package/dist/bin/test-command.d.ts +7 -0
  33. package/dist/bin/test-command.d.ts.map +1 -1
  34. package/dist/bin/test-command.js +18 -4
  35. package/dist/bin/typecheck-web.d.ts +2 -0
  36. package/dist/bin/typecheck-web.d.ts.map +1 -0
  37. package/dist/bin/typecheck-web.js +36 -0
  38. package/dist/cache/decorator.d.ts.map +1 -1
  39. package/dist/cache/drivers.d.ts.map +1 -1
  40. package/dist/cache-control/cache-control.d.ts.map +1 -1
  41. package/dist/compress/compress.d.ts.map +1 -1
  42. package/dist/cone/cone-generator.js +1 -1
  43. package/dist/database/base-model.d.ts.map +1 -1
  44. package/dist/database/base-model.types.d.ts +0 -1
  45. package/dist/database/base-model.types.d.ts.map +1 -1
  46. package/dist/database/db.d.ts.map +1 -1
  47. package/dist/database/puri-subset.types.d.ts +0 -1
  48. package/dist/database/puri-subset.types.d.ts.map +1 -1
  49. package/dist/database/puri-wrapper.d.ts +0 -1
  50. package/dist/database/puri-wrapper.d.ts.map +1 -1
  51. package/dist/database/puri.d.ts +0 -1
  52. package/dist/database/puri.d.ts.map +1 -1
  53. package/dist/database/puri.types.d.ts +0 -1
  54. package/dist/database/puri.types.d.ts.map +1 -1
  55. package/dist/database/transaction-context.d.ts.map +1 -1
  56. package/dist/database/upsert-builder.d.ts.map +1 -1
  57. package/dist/dict/en.d.ts +58 -58
  58. package/dist/dict/en.d.ts.map +1 -1
  59. package/dist/dict/ko.d.ts +58 -58
  60. package/dist/dict/ko.d.ts.map +1 -1
  61. package/dist/dict/rc-keys.d.ts.map +1 -1
  62. package/dist/dict/sonamu-dictionary.d.ts.map +1 -1
  63. package/dist/dict/sonamu-dictionary.js +2 -2
  64. package/dist/dict/utils.d.ts.map +1 -1
  65. package/dist/entity/entity-manager.d.ts +115 -113
  66. package/dist/entity/entity-manager.d.ts.map +1 -1
  67. package/dist/entity/entity-manager.js +25 -19
  68. package/dist/entity/entity-template-cone.d.ts +4 -4
  69. package/dist/entity/entity.d.ts +6 -1
  70. package/dist/entity/entity.d.ts.map +1 -1
  71. package/dist/entity/entity.js +8 -3
  72. package/dist/env.d.ts.map +1 -1
  73. package/dist/exceptions/so-exceptions.d.ts.map +1 -1
  74. package/dist/filter/types.d.ts.map +1 -1
  75. package/dist/index.js +6 -6
  76. package/dist/logger/configure.d.ts +10 -1
  77. package/dist/logger/configure.d.ts.map +1 -1
  78. package/dist/logger/configure.js +30 -5
  79. package/dist/migration/migrator.d.ts +12 -1
  80. package/dist/migration/migrator.d.ts.map +1 -1
  81. package/dist/migration/migrator.js +77 -30
  82. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  83. package/dist/migration/slack-confirm.d.ts.map +1 -1
  84. package/dist/naite/messaging-types.d.ts +0 -1
  85. package/dist/naite/messaging-types.d.ts.map +1 -1
  86. package/dist/naite/naite-reporter.d.ts +0 -1
  87. package/dist/naite/naite-reporter.d.ts.map +1 -1
  88. package/dist/naite/naite.d.ts.map +1 -1
  89. package/dist/storage/base-file.d.ts.map +1 -1
  90. package/dist/storage/buffered-file.d.ts.map +1 -1
  91. package/dist/storage/drivers.d.ts.map +1 -1
  92. package/dist/storage/s3-driver.d.ts.map +1 -1
  93. package/dist/storage/storage-manager.d.ts.map +1 -1
  94. package/dist/storage/uploaded-file.d.ts.map +1 -1
  95. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  96. package/dist/stream/ws-audience.d.ts.map +1 -1
  97. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  98. package/dist/stream/ws-delivery.d.ts.map +1 -1
  99. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  100. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  101. package/dist/stream/ws-registry.d.ts.map +1 -1
  102. package/dist/stream/ws-telemetry-memory.d.ts.map +1 -1
  103. package/dist/stream/ws-telemetry.d.ts.map +1 -1
  104. package/dist/stream/ws.d.ts.map +1 -1
  105. package/dist/syncer/api-parser.js +2 -2
  106. package/dist/syncer/file-patterns.d.ts +17 -17
  107. package/dist/syncer/filesystem-dependencies.js +1 -1
  108. package/dist/syncer/syncer.d.ts.map +1 -1
  109. package/dist/tasks/step-wrapper.d.ts.map +1 -1
  110. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  111. package/dist/template/implementations/entity.template.d.ts +2 -2
  112. package/dist/template/implementations/entity.template.d.ts.map +1 -1
  113. package/dist/template/implementations/entry-server.template.d.ts +2 -2
  114. package/dist/template/implementations/entry-server.template.d.ts.map +1 -1
  115. package/dist/template/implementations/generated.template.d.ts +2 -2
  116. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  117. package/dist/template/implementations/generated_http.template.d.ts +2 -2
  118. package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
  119. package/dist/template/implementations/generated_sso.template.d.ts +2 -2
  120. package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
  121. package/dist/template/implementations/http_validators.template.d.ts +2 -2
  122. package/dist/template/implementations/http_validators.template.d.ts.map +1 -1
  123. package/dist/template/implementations/init_types.template.d.ts +2 -2
  124. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  125. package/dist/template/implementations/model.template.d.ts +2 -2
  126. package/dist/template/implementations/model.template.d.ts.map +1 -1
  127. package/dist/template/implementations/model_test.template.d.ts +2 -2
  128. package/dist/template/implementations/model_test.template.d.ts.map +1 -1
  129. package/dist/template/implementations/queries.template.d.ts +2 -2
  130. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  131. package/dist/template/implementations/sd.template.d.ts +2 -2
  132. package/dist/template/implementations/sd.template.d.ts.map +1 -1
  133. package/dist/template/implementations/sd.template.js +1 -1
  134. package/dist/template/implementations/services.template.d.ts +2 -2
  135. package/dist/template/implementations/services.template.d.ts.map +1 -1
  136. package/dist/template/implementations/view_form.template.d.ts +3 -3
  137. package/dist/template/implementations/view_form.template.d.ts.map +1 -1
  138. package/dist/template/implementations/view_id_all_select.template.d.ts +2 -2
  139. package/dist/template/implementations/view_id_all_select.template.d.ts.map +1 -1
  140. package/dist/template/implementations/view_list.template.d.ts +2 -2
  141. package/dist/template/implementations/view_list.template.d.ts.map +1 -1
  142. package/dist/template/implementations/view_search_input.template.d.ts +2 -2
  143. package/dist/template/implementations/view_search_input.template.d.ts.map +1 -1
  144. package/dist/template/template-manager.d.ts.map +1 -1
  145. package/dist/template/template.d.ts.map +1 -1
  146. package/dist/template/zod-converter.d.ts.map +1 -1
  147. package/dist/testing/_relation-graph.d.ts.map +1 -1
  148. package/dist/testing/data-explorer.d.ts +2 -2
  149. package/dist/testing/data-explorer.d.ts.map +1 -1
  150. package/dist/testing/dev-vitest-manager.d.ts.map +1 -1
  151. package/dist/testing/faker-mappings.d.ts.map +1 -1
  152. package/dist/testing/fixture-generator.d.ts.map +1 -1
  153. package/dist/testing/fixture-generator.js +1 -1
  154. package/dist/testing/fixture-loader.d.ts.map +1 -1
  155. package/dist/testing/fixture-manager.d.ts +4 -0
  156. package/dist/testing/fixture-manager.d.ts.map +1 -1
  157. package/dist/testing/fixture-manager.js +99 -25
  158. package/dist/testing/naite-vitest-reporter.d.ts.map +1 -1
  159. package/dist/testing/parallel-db-manager.d.ts.map +1 -1
  160. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts +10 -0
  161. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts.map +1 -0
  162. package/dist/tooling/__tests__/helpers/sonamu-test-root.js +42 -0
  163. package/dist/tooling/cdd-service.d.ts +87 -0
  164. package/dist/tooling/cdd-service.d.ts.map +1 -0
  165. package/dist/tooling/cdd-service.js +215 -0
  166. package/dist/tooling/cli-tooling.d.ts +11 -0
  167. package/dist/tooling/cli-tooling.d.ts.map +1 -0
  168. package/dist/tooling/cli-tooling.js +901 -0
  169. package/dist/tooling/fixture-service.d.ts +29 -0
  170. package/dist/tooling/fixture-service.d.ts.map +1 -0
  171. package/dist/tooling/fixture-service.js +53 -0
  172. package/dist/tooling/index.d.ts +2 -0
  173. package/dist/tooling/index.d.ts.map +1 -0
  174. package/dist/tooling/index.js +2 -0
  175. package/dist/tooling/test-run-client.d.ts +27 -0
  176. package/dist/tooling/test-run-client.d.ts.map +1 -0
  177. package/dist/tooling/test-run-client.js +120 -0
  178. package/dist/tsdown.api.config.js +48 -0
  179. package/dist/types/types.d.ts +527 -527
  180. package/dist/types/types.d.ts.map +1 -1
  181. package/dist/ui/ai-client.d.ts.map +1 -1
  182. package/dist/utils/formatter.d.ts +6 -0
  183. package/dist/utils/formatter.d.ts.map +1 -1
  184. package/dist/utils/formatter.js +42 -11
  185. package/dist/utils/model.d.ts +1 -1
  186. package/dist/vector/chunking.d.ts.map +1 -1
  187. package/dist/vector/embedding.d.ts.map +1 -1
  188. package/package.json +13 -12
  189. package/src/api/sonamu.ts +2 -1
  190. package/src/auth/index.ts +1 -1
  191. package/src/bin/__tests__/zod-compiler-build.test.ts +67 -0
  192. package/src/bin/build-config.ts +151 -5
  193. package/src/bin/fixture.ts +204 -21
  194. package/src/bin/test-command.ts +27 -4
  195. package/src/bin/typecheck-web.ts +56 -0
  196. package/src/dict/sonamu-dictionary.ts +1 -1
  197. package/src/entity/__tests__/entity-manager-autoload.contract.test.ts +55 -0
  198. package/src/entity/__tests__/entity-manager-explicit-root-registration.contract.test.ts +107 -0
  199. package/src/entity/entity-manager.ts +31 -19
  200. package/src/entity/entity.ts +7 -2
  201. package/src/logger/__tests__/configure.test.ts +33 -0
  202. package/src/logger/configure.ts +45 -3
  203. package/src/migration/__tests__/migrator.test.ts +51 -0
  204. package/src/migration/migrator.ts +103 -34
  205. package/src/syncer/api-parser.ts +1 -1
  206. package/src/testing/fixture-manager.ts +124 -32
  207. package/src/tooling/__tests__/cdd-rule-bootstrap.contract.test.ts +118 -0
  208. package/src/tooling/__tests__/cli-tooling-auth.contract.test.ts +72 -0
  209. package/src/tooling/__tests__/cli-tooling-cone-locale.contract.test.ts +150 -0
  210. package/src/tooling/__tests__/cli-tooling-entity-patch.contract.test.ts +131 -0
  211. package/src/tooling/__tests__/cli-tooling-fixture-transfer.contract.test.ts +255 -0
  212. package/src/tooling/__tests__/cli-tooling-i18n-update.contract.test.ts +104 -0
  213. package/src/tooling/__tests__/cli-tooling-scaffold.contract.test.ts +86 -0
  214. package/src/tooling/__tests__/cli-tooling-stub-practice.contract.test.ts +147 -0
  215. package/src/tooling/__tests__/default-cdd-adapter.contract.test.ts +73 -0
  216. package/src/tooling/__tests__/helpers/sonamu-test-root.ts +42 -0
  217. package/src/tooling/__tests__/test-run-client-config.contract.test.ts +154 -0
  218. package/src/tooling/cdd-service.ts +256 -0
  219. package/src/tooling/cli-tooling.ts +937 -0
  220. package/src/tooling/fixture-service.ts +72 -0
  221. package/src/tooling/index.ts +1 -0
  222. package/src/tooling/test-run-client.ts +196 -0
  223. package/src/utils/__tests__/formatter-bin.test.ts +99 -0
  224. package/src/utils/formatter.ts +74 -10
  225. package/tsdown.api.config.ts +8 -10
  226. package/bin/cli.js +0 -75
  227. package/dist/bin/cli.d.ts +0 -2
  228. package/dist/bin/cli.d.ts.map +0 -1
  229. package/dist/bin/cli.js +0 -956
  230. package/src/bin/cli.ts +0 -1166
@@ -0,0 +1,107 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { Sonamu } from "../../api/sonamu";
8
+ import { EntityManager } from "../entity-manager";
9
+
10
+ const temporaryRoots: string[] = [];
11
+
12
+ const POST_ENTITY_JSON = {
13
+ id: "Post",
14
+ table: "posts",
15
+ title: "게시글",
16
+ props: [
17
+ { name: "id", type: "integer" },
18
+ { name: "title", type: "string", length: 128 },
19
+ ],
20
+ indexes: [],
21
+ subsets: {},
22
+ enums: {},
23
+ };
24
+
25
+ async function createApiRootWithEntity(): Promise<string> {
26
+ const apiRootPath = await mkdtemp(path.join(os.tmpdir(), "sonamu-explicit-root-"));
27
+ temporaryRoots.push(apiRootPath);
28
+ const entityDirectory = path.join(apiRootPath, "src", "application", "post");
29
+ await mkdir(entityDirectory, { recursive: true });
30
+ await writeFile(
31
+ path.join(entityDirectory, "post.entity.json"),
32
+ JSON.stringify(POST_ENTITY_JSON, null, 2),
33
+ "utf8",
34
+ );
35
+ return apiRootPath;
36
+ }
37
+
38
+ afterEach(async () => {
39
+ await Promise.all(
40
+ temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
41
+ );
42
+ });
43
+
44
+ describe("EntityManager 명시적 API 루트 등록", () => {
45
+ it("Sonamu 초기화 전에도 명시한 루트의 entity.json을 등록한다", async () => {
46
+ const apiRootPath = await createApiRootWithEntity();
47
+ const previousAutoloaded = EntityManager.isAutoloaded;
48
+ EntityManager.isAutoloaded = false;
49
+
50
+ // 명시적 루트 경로는 Sonamu singleton 초기화 없이 동작해야 합니다.
51
+ expect(() => Sonamu.apiRootPath).toThrow(/init/i);
52
+ try {
53
+ await expect(EntityManager.autoload(true, apiRootPath)).resolves.toBeUndefined();
54
+
55
+ expect(EntityManager.isAutoloaded).toBe(true);
56
+ expect(EntityManager.exists("Post")).toBe(true);
57
+ expect(EntityManager.get("Post").table).toBe("posts");
58
+ expect(EntityManager.modulePaths.get("PostBaseSchema")).toBe("sonamu.generated");
59
+ } finally {
60
+ EntityManager.isAutoloaded = previousAutoloaded;
61
+ }
62
+ });
63
+
64
+ it("reload()는 autoload에 넘긴 명시적 루트를 그대로 다시 읽는다", async () => {
65
+ const apiRootPath = await createApiRootWithEntity();
66
+ const previousAutoloaded = EntityManager.isAutoloaded;
67
+ EntityManager.isAutoloaded = false;
68
+
69
+ expect(() => Sonamu.apiRootPath).toThrow(/init/i);
70
+ try {
71
+ await EntityManager.autoload(true, apiRootPath);
72
+ expect(EntityManager.exists("Post")).toBe(true);
73
+
74
+ // 보존한 루트를 잃으면 초기화되지 않은 Sonamu.apiRootPath로 폴백해 throw합니다.
75
+ await expect(EntityManager.reload(true)).resolves.toBeUndefined();
76
+ expect(EntityManager.exists("Post")).toBe(true);
77
+ expect(EntityManager.get("Post").table).toBe("posts");
78
+ } finally {
79
+ EntityManager.isAutoloaded = previousAutoloaded;
80
+ }
81
+ });
82
+
83
+ it("reload()에 넘긴 루트는 보존된 루트보다 우선한다", async () => {
84
+ const firstRootPath = await createApiRootWithEntity();
85
+ const secondRootPath = await createApiRootWithEntity();
86
+ // 두 번째 루트에만 존재하는 entity로 어느 루트를 읽었는지 구분합니다.
87
+ const commentDirectory = path.join(secondRootPath, "src", "application", "comment");
88
+ await mkdir(commentDirectory, { recursive: true });
89
+ await writeFile(
90
+ path.join(commentDirectory, "comment.entity.json"),
91
+ JSON.stringify({ ...POST_ENTITY_JSON, id: "Comment", table: "comments" }, null, 2),
92
+ "utf8",
93
+ );
94
+
95
+ const previousAutoloaded = EntityManager.isAutoloaded;
96
+ EntityManager.isAutoloaded = false;
97
+ try {
98
+ await EntityManager.autoload(true, firstRootPath);
99
+ expect(EntityManager.exists("Comment")).toBe(false);
100
+
101
+ await EntityManager.reload(true, secondRootPath);
102
+ expect(EntityManager.exists("Comment")).toBe(true);
103
+ } finally {
104
+ EntityManager.isAutoloaded = previousAutoloaded;
105
+ }
106
+ });
107
+ });
@@ -48,13 +48,17 @@ class EntityManagerClass {
48
48
  public modulePaths: Map<string, string> = new Map();
49
49
  private tableSpecs: Map<string, TableSpec> = new Map();
50
50
  public isAutoloaded: boolean = false;
51
+ // reload()가 Sonamu 초기화 없이도 같은 루트를 다시 읽도록 명시적 api root를 보존합니다.
52
+ private explicitApiRootPath: string | undefined;
51
53
 
52
54
  // 경로 전달받아 모든 entity.json 파일 로드
53
- async autoload(doSilent: boolean = false) {
55
+ async autoload(doSilent: boolean = false, explicitApiRootPath?: string) {
54
56
  if (this.isAutoloaded) {
55
57
  return;
56
58
  }
57
- const pathPattern = path.join(Sonamu.apiRootPath, "/src/application/**/*.entity.json");
59
+ this.explicitApiRootPath = explicitApiRootPath;
60
+ const apiRootPath = explicitApiRootPath ?? Sonamu.apiRootPath;
61
+ const pathPattern = path.join(apiRootPath, "/src/application/**/*.entity.json");
58
62
 
59
63
  for await (const file of glob(path.resolve(pathPattern))) {
60
64
  const json = parseEntityJson((await readFile(file)).toString());
@@ -62,7 +66,7 @@ class EntityManagerClass {
62
66
  // entity.json 스키마 검증
63
67
  const error = this.schemaValidate(json);
64
68
  if (error) {
65
- const relativePath = path.relative(Sonamu.apiRootPath, file);
69
+ const relativePath = path.relative(apiRootPath, file);
66
70
  const errorMessage = prettifyError(error);
67
71
  if (!doSilent) {
68
72
  console.error(
@@ -71,11 +75,14 @@ class EntityManagerClass {
71
75
  }
72
76
  }
73
77
 
74
- await this.register(json, { deferSearchTextJsonSourceValidation: true });
78
+ await this.register(json, {
79
+ deferSearchTextJsonSourceValidation: true,
80
+ apiRootPath,
81
+ });
75
82
  }
76
83
 
77
- await this.registerNonEntityTypeModulePaths();
78
- await this.validateAllRegisteredSearchTextJsonSources();
84
+ await this.registerNonEntityTypeModulePaths(apiRootPath);
85
+ await this.validateAllRegisteredSearchTextJsonSources(apiRootPath);
79
86
 
80
87
  this.isAutoloaded = true;
81
88
  }
@@ -85,36 +92,36 @@ class EntityManagerClass {
85
92
  return result.success ? null : result.error;
86
93
  }
87
94
 
88
- async reload(doSilent: boolean = false) {
95
+ async reload(doSilent: boolean = false, explicitApiRootPath?: string) {
89
96
  this.entities.clear();
90
97
  this.modulePaths.clear();
91
98
  this.tableSpecs.clear();
92
99
  this.isAutoloaded = false;
93
100
 
94
- return await this.autoload(doSilent);
101
+ return await this.autoload(doSilent, explicitApiRootPath ?? this.explicitApiRootPath);
95
102
  }
96
103
 
97
104
  async register(
98
105
  json: EntityJson,
99
- options: { deferSearchTextJsonSourceValidation?: boolean } = {},
106
+ options: { deferSearchTextJsonSourceValidation?: boolean; apiRootPath?: string } = {},
100
107
  ): Promise<void> {
101
108
  const { Entity } = await import("./entity");
102
109
  const entity = new Entity(json);
103
- await entity.registerModulePaths();
110
+ await entity.registerModulePaths(options.apiRootPath);
104
111
  if (!options.deferSearchTextJsonSourceValidation) {
105
- await this.validateSearchTextJsonSources(entity);
112
+ await this.validateSearchTextJsonSources(entity, options.apiRootPath);
106
113
  }
107
114
  entity.registerTableSpecs();
108
115
  this.entities.set(json.id, entity);
109
116
  }
110
117
 
111
- async validateAllRegisteredSearchTextJsonSources(): Promise<void> {
118
+ async validateAllRegisteredSearchTextJsonSources(apiRootPath?: string): Promise<void> {
112
119
  for (const entity of this.entities.values()) {
113
- await this.validateSearchTextJsonSources(entity);
120
+ await this.validateSearchTextJsonSources(entity, apiRootPath);
114
121
  }
115
122
  }
116
123
 
117
- private async validateSearchTextJsonSources(entity: Entity): Promise<void> {
124
+ private async validateSearchTextJsonSources(entity: Entity, apiRootPath?: string): Promise<void> {
118
125
  const propsByName = new Map(entity.props.map((prop) => [prop.name, prop]));
119
126
 
120
127
  for (const prop of entity.props) {
@@ -128,7 +135,11 @@ class EntityManagerClass {
128
135
  continue;
129
136
  }
130
137
 
131
- const zodType = await this.resolveSearchTextJsonSourceType(entity, sourceProp.id);
138
+ const zodType = await this.resolveSearchTextJsonSourceType(
139
+ entity,
140
+ sourceProp.id,
141
+ apiRootPath,
142
+ );
132
143
  if (!zodType) {
133
144
  throw new Error(
134
145
  `searchText source "${source.name}"의 json 타입 "${sourceProp.id}"을(를) 로드할 수 없습니다.`,
@@ -147,6 +158,7 @@ class EntityManagerClass {
147
158
  private async resolveSearchTextJsonSourceType(
148
159
  entity: Entity,
149
160
  typeId: string,
161
+ explicitApiRootPath?: string,
150
162
  ): Promise<z.ZodTypeAny | null> {
151
163
  const localType = entity.types[typeId];
152
164
  if (localType instanceof z.ZodType) {
@@ -173,7 +185,7 @@ class EntityManagerClass {
173
185
  }
174
186
 
175
187
  const moduleFilePath = path.join(
176
- Sonamu.apiRootPath,
188
+ explicitApiRootPath ?? Sonamu.apiRootPath,
177
189
  runtimePath(`dist/application/${modulePath}.js`),
178
190
  );
179
191
  const importedMembers = await importMembers<unknown>(moduleFilePath);
@@ -296,10 +308,10 @@ class EntityManagerClass {
296
308
  return inflection.camelize(entityBaseName.replace(/-/g, "_"));
297
309
  }
298
310
 
299
- private async registerNonEntityTypeModulePaths(): Promise<void> {
311
+ private async registerNonEntityTypeModulePaths(apiRootPath: string): Promise<void> {
300
312
  const typePathsPatterns = [
301
- path.join(Sonamu.apiRootPath, runtimePath("src/application/**/*.types.ts")),
302
- path.join(Sonamu.apiRootPath, runtimePath("src/application/**/*.generated.ts")),
313
+ path.join(apiRootPath, runtimePath("src/application/**/*.types.ts")),
314
+ path.join(apiRootPath, runtimePath("src/application/**/*.generated.ts")),
303
315
  ];
304
316
  const typePaths = (
305
317
  await Promise.all(typePathsPatterns.map((pattern) => globAsync(pattern)))
@@ -827,7 +827,12 @@ export class Entity {
827
827
  });
828
828
  }
829
829
 
830
- async registerModulePaths() {
830
+ /**
831
+ * Entity가 노출하는 타입 이름과 모듈 경로를 EntityManager에 등록합니다.
832
+ *
833
+ * @param explicitApiRootPath - Sonamu 초기화 없이 등록할 때 사용할 API 루트 경로
834
+ */
835
+ async registerModulePaths(explicitApiRootPath?: string) {
831
836
  const basePath = `${this.names.parentFs}`;
832
837
 
833
838
  // base-scheme
@@ -853,7 +858,7 @@ export class Entity {
853
858
  // types
854
859
  const typesModulePath = `${basePath}/${this.names.parentFs}.types`;
855
860
  const typesFilePath = path.join(
856
- Sonamu.apiRootPath,
861
+ explicitApiRootPath ?? Sonamu.apiRootPath,
857
862
  runtimePath(`dist/application/${typesModulePath}.js`),
858
863
  );
859
864
 
@@ -0,0 +1,33 @@
1
+ import { dispose, getLogger, type Config, type LogRecord } from "@logtape/logtape";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { configureLogTape } from "../configure";
5
+
6
+ describe("CLI LogTape 설정 합성", () => {
7
+ afterEach(async () => {
8
+ globalThis.sonamuKitCliLogTapeOverride = undefined;
9
+ await dispose();
10
+ });
11
+
12
+ it("CLI 출력 설정과 프로젝트 카테고리 설정을 함께 유지한다", async () => {
13
+ const cliRecords: LogRecord[] = [];
14
+ const appRecords: LogRecord[] = [];
15
+ const cliConfig: Config<string, string> = {
16
+ sinks: { default: (record) => cliRecords.push(record) },
17
+ loggers: [{ category: [], lowestLevel: "debug", sinks: ["default"] }],
18
+ reset: true,
19
+ };
20
+ globalThis.sonamuKitCliLogTapeOverride = { config: cliConfig, applied: false };
21
+
22
+ await configureLogTape({
23
+ sinks: { default: (record) => appRecords.push(record) },
24
+ loggers: [{ category: ["app"], lowestLevel: "debug", sinks: ["default"] }],
25
+ });
26
+ getLogger(["cli"]).debug("CLI 로그");
27
+ getLogger(["app"]).debug("프로젝트 로그");
28
+
29
+ expect(globalThis.sonamuKitCliLogTapeOverride.applied).toBe(true);
30
+ expect(cliRecords.map((record) => record.category[0])).toEqual(["cli", "app"]);
31
+ expect(appRecords).toHaveLength(1);
32
+ });
33
+ });
@@ -1,5 +1,6 @@
1
1
  import { configure, getConsoleSink } from "@logtape/logtape";
2
2
  import {
3
+ type Config,
3
4
  type Filter,
4
5
  type FilterLike,
5
6
  type LoggerConfig,
@@ -12,6 +13,23 @@ import { type FastifyReply, type FastifyRequest } from "fastify";
12
13
 
13
14
  import { isSameCategory } from "./category";
14
15
 
16
+ interface ExternalLogTapeConfiguration {
17
+ readonly config: Config<string, string>;
18
+ applied: boolean;
19
+ }
20
+
21
+ declare global {
22
+ var sonamuKitCliLogTapeOverride: ExternalLogTapeConfiguration | undefined;
23
+ }
24
+
25
+ function getExternalLogTapeConfiguration(): ExternalLogTapeConfiguration | undefined {
26
+ return globalThis.sonamuKitCliLogTapeOverride;
27
+ }
28
+
29
+ export function hasExternalLogTapeConfiguration(): boolean {
30
+ return getExternalLogTapeConfiguration() !== undefined;
31
+ }
32
+
15
33
  export type SonamuLoggingOptions<TSinkId extends string, TFilterId extends string> = {
16
34
  // fastify 로깅 카테고리 (a.b.c의 형태로 넣으면 [a, b, c]로 들어갑니다.)
17
35
  // 기본값은 ["fastify"] 입니다.
@@ -107,19 +125,42 @@ function defaultFastifyFilter(fastifyCategory: readonly string[]): Filter {
107
125
  export async function configureLogTape<TSinkId extends string, TFilterId extends string>(
108
126
  options: SonamuLoggingOptions<TSinkId, TFilterId>,
109
127
  ) {
128
+ const external = getExternalLogTapeConfiguration();
110
129
  const fastifyCategory = options.fastifyCategory ?? ["fastify"];
111
130
 
112
- const sinks = {
131
+ const projectSinks = {
113
132
  "fastify-console": defaultFastifySink(fastifyCategory),
114
133
  ...options.sinks,
115
- };
134
+ } satisfies Record<string, Sink>;
135
+ const externalSinkIds = new Map<string, string>();
136
+ const externalSinks: Record<string, Sink> = {};
137
+ for (const [sinkId, sink] of Object.entries(external?.config.sinks ?? {})) {
138
+ let namespacedId = `sonamu-cli:${sinkId}`;
139
+ while (
140
+ Object.hasOwn(projectSinks, namespacedId) ||
141
+ Object.hasOwn(externalSinks, namespacedId)
142
+ ) {
143
+ namespacedId = `${namespacedId}-`;
144
+ }
145
+ externalSinkIds.set(sinkId, namespacedId);
146
+ externalSinks[namespacedId] = sink;
147
+ }
148
+ const sinks = { ...projectSinks, ...externalSinks };
116
149
 
117
150
  const filters = {
118
151
  "fastify-console": defaultFastifyFilter(fastifyCategory),
119
152
  ...options.filters,
153
+ ...external?.config.filters,
120
154
  };
121
155
 
122
- const loggers = new Set<LoggerConfig<string, string>>(options.loggers ?? []);
156
+ const externalLoggers = (external?.config.loggers ?? []).map((logger) => ({
157
+ ...logger,
158
+ sinks: logger.sinks?.map((sinkId) => externalSinkIds.get(sinkId) ?? sinkId),
159
+ }));
160
+ const loggers = new Set<LoggerConfig<string, string>>([
161
+ ...externalLoggers,
162
+ ...(options.loggers ?? []),
163
+ ]);
123
164
 
124
165
  // logtape의 meta logger 표시를 비활성화
125
166
  loggers.add({
@@ -136,5 +177,6 @@ export async function configureLogTape<TSinkId extends string, TFilterId extends
136
177
  });
137
178
  }
138
179
 
180
+ if (external !== undefined) external.applied = true;
139
181
  return configure({ sinks, filters, loggers: [...loggers], reset: true });
140
182
  }
@@ -1,3 +1,5 @@
1
+ import { type Knex } from "knex";
2
+ import knex from "knex";
1
3
  import { afterEach, describe, expect, it } from "vitest";
2
4
 
3
5
  import { type SonamuConfig } from "../../api/config";
@@ -89,6 +91,55 @@ describe("Migrator environment target filtering", () => {
89
91
  });
90
92
  });
91
93
 
94
+ describe("Migrator 최신 적용 batch 조회", () => {
95
+ const presets: SonamuDBPreset[] = [
96
+ "test",
97
+ "fixture",
98
+ "development",
99
+ "staging",
100
+ "production",
101
+ "test_readonly",
102
+ "development_readonly",
103
+ "staging_readonly",
104
+ "production_readonly",
105
+ ];
106
+ const baseDbConfig =
107
+ /* SAFETY: 모든 Sonamu DB preset에 유효한 PostgreSQL 테스트 설정을 제공합니다. */ Object.fromEntries(
108
+ presets.map((preset) => [preset, { client: "postgresql", connection: { database: preset } }]),
109
+ ) as SonamuDBConfig;
110
+
111
+ afterEach(() => {
112
+ Sonamu.dbConfig = baseDbConfig;
113
+ });
114
+
115
+ it("PostgreSQL 42P01은 사용자 migration table 이름과 오류 문구에 관계없이 미적용 상태로 처리한다", async () => {
116
+ const clientTemplate = knex({ client: "pg" });
117
+ // SAFETY: knex({ client: "pg" })가 생성한 실제 PostgreSQL client 생성자입니다.
118
+ const PostgreSqlClient = clientTemplate.client.constructor as typeof Knex.Client;
119
+ class MissingMigrationTableClient extends PostgreSqlClient {
120
+ async acquireRawConnection(): Promise<never> {
121
+ throw Object.assign(new Error('relation "sonamu_history" does not exist'), {
122
+ code: "42P01",
123
+ });
124
+ }
125
+ }
126
+ await clientTemplate.destroy();
127
+ Sonamu.dbConfig = {
128
+ ...baseDbConfig,
129
+ development: {
130
+ client: MissingMigrationTableClient,
131
+ connection: { database: "sonamu_test" },
132
+ migrations: { tableName: "sonamu_history" },
133
+ },
134
+ };
135
+
136
+ await expect(new Migrator().getLatestAppliedBatch("development")).resolves.toEqual({
137
+ batchNo: 0,
138
+ files: [],
139
+ });
140
+ });
141
+ });
142
+
92
143
  describe("SlackConfirm target validation", () => {
93
144
  const presets: SonamuDBPreset[] = [
94
145
  "test",
@@ -41,6 +41,8 @@ export type MigrationResult = {
41
41
  applied: string[];
42
42
  }[];
43
43
 
44
+ type ShadowMigrationTarget = Extract<MigrationTarget, "fixture" | "test">;
45
+
44
46
  function padDatePart(number: number, size: number = 2): string {
45
47
  return number.toString().padStart(size, "0");
46
48
  }
@@ -95,13 +97,9 @@ export class Migrator {
95
97
  const maybePostgresError =
96
98
  /* SAFETY: Knex와 PostgreSQL 스키마 조회 계약이 이 값의 타입을 보장한다. */ error as {
97
99
  code?: unknown;
98
- message?: unknown;
99
100
  };
100
- return (
101
- maybePostgresError.code === "42P01" &&
102
- isStringValue(maybePostgresError.message) &&
103
- maybePostgresError.message.includes("knex_migrations")
104
- );
101
+ // PostgreSQL undefined_table 코드는 사용자 지정 migration table에도 동일하게 적용됩니다.
102
+ return maybePostgresError.code === "42P01";
105
103
  }
106
104
 
107
105
  /** 현재 실행 환경에서 허용된 마이그레이션 대상 키를 반환합니다. */
@@ -375,6 +373,61 @@ export class Migrator {
375
373
  }
376
374
  }
377
375
 
376
+ /**
377
+ * 대상 DB에서 가장 최근에 적용된 migration batch를 반환합니다.
378
+ *
379
+ * @category 분리형 마이그레이션 API
380
+ */
381
+ async getLatestAppliedBatch(
382
+ connKey: MigrationTarget,
383
+ ): Promise<{ batchNo: number; files: string[] }> {
384
+ this.assertMigrationTarget(connKey);
385
+ const knexOptions = Sonamu.dbConfig[connKey];
386
+ const connection =
387
+ /* SAFETY: Knex와 PostgreSQL 스키마 조회 계약이 이 값의 타입을 보장한다. */ knexOptions.connection as Knex.PgConnectionConfig;
388
+ const tConn = createKnexInstance({
389
+ ...knexOptions,
390
+ connection: {
391
+ ...connection,
392
+ connectionTimeoutMillis: MIGRATION_CONN_TIMEOUT_MS,
393
+ },
394
+ pool: {
395
+ ...knexOptions.pool,
396
+ min: 0,
397
+ acquireTimeoutMillis: MIGRATION_CONN_TIMEOUT_MS,
398
+ createTimeoutMillis: MIGRATION_CONN_TIMEOUT_MS,
399
+ propagateCreateError: true,
400
+ },
401
+ });
402
+
403
+ try {
404
+ const tableName = knexOptions.migrations?.tableName ?? "knex_migrations";
405
+ const schemaName = knexOptions.migrations?.schemaName;
406
+ const migrationsTable =
407
+ schemaName === undefined
408
+ ? tConn<{ id: number; name: string; batch: number }>(tableName)
409
+ : tConn<{ id: number; name: string; batch: number }>(tableName).withSchema(schemaName);
410
+ const latest = await migrationsTable.clone().select("batch").orderBy("batch", "desc").first();
411
+ if (latest === undefined) return { batchNo: 0, files: [] };
412
+
413
+ // 같은 batch 안에서는 Knex 실행 순서대로 롤백 계획을 고정한다.
414
+ const rows = await migrationsTable
415
+ .clone()
416
+ .select("name")
417
+ .where("batch", latest.batch)
418
+ .orderBy("id", "asc");
419
+ return {
420
+ batchNo: latest.batch,
421
+ files: rows.map(({ name }) => name.replace(/\.ts$/, "")),
422
+ };
423
+ } catch (caught) {
424
+ if (this.isMissingMigrationTableError(caught)) return { batchNo: 0, files: [] };
425
+ throw caught;
426
+ } finally {
427
+ await tConn.destroy();
428
+ }
429
+ }
430
+
378
431
  /**
379
432
  * 최신 상태인 기준 DB와 엔티티 정의를 비교해 생성 예정 코드를 반환합니다.
380
433
  *
@@ -659,15 +712,41 @@ export class Migrator {
659
712
  *
660
713
  * @category 분리형 마이그레이션 API
661
714
  */
662
- async runShadowTest(options?: MigrationRunOptions): Promise<MigrationResult> {
663
- const baseTestConn =
664
- /* SAFETY: Knex와 PostgreSQL 스키마 조회 계약이 이 값의 타입을 보장한다. */ Sonamu.dbConfig
665
- .test.connection as Knex.PgConnectionConfig;
666
- const workerId = process.env.SONAMU_WORKER_DB === "true" ? process.env.VITEST_POOL_ID : null;
715
+ async runShadowTest(
716
+ options?: MigrationRunOptions & { target?: MigrationTarget },
717
+ ): Promise<MigrationResult> {
718
+ const target = options?.target ?? "test";
719
+ if (target !== "test" && target !== "fixture") {
720
+ // 운영 계열 DB는 설정 조회나 연결 생성보다 먼저 차단한다.
721
+ throw Object.assign(new Error(`지원하지 않는 Shadow 마이그레이션 대상입니다: ${target}`), {
722
+ code: "INVALID_SHADOW_MIGRATION_TARGET",
723
+ target,
724
+ });
725
+ }
726
+ const shadowTarget: ShadowMigrationTarget = target;
727
+ const baseTargetConn =
728
+ /* SAFETY: Knex와 PostgreSQL 스키마 조회 계약이 이 값의 타입을 보장한다. */ Sonamu.dbConfig[
729
+ shadowTarget
730
+ ].connection as Knex.PgConnectionConfig;
731
+ const configuredDatabase = baseTargetConn.database;
732
+ if (!configuredDatabase) {
733
+ throw Object.assign(new Error(`Shadow 대상 DB 이름이 없습니다: ${shadowTarget}`), {
734
+ code: "INVALID_SHADOW_DATABASE_CONFIG",
735
+ target: shadowTarget,
736
+ });
737
+ }
738
+ const workerId =
739
+ shadowTarget === "test" && process.env.SONAMU_WORKER_DB === "true"
740
+ ? process.env.VITEST_POOL_ID
741
+ : null;
667
742
  const templateDatabase =
668
- workerId !== null ? `${baseTestConn.database}_${workerId ?? "1"}` : baseTestConn.database;
669
- const tdbConn = { ...baseTestConn, database: templateDatabase };
743
+ workerId !== null ? `${configuredDatabase}_${workerId ?? "1"}` : configuredDatabase;
670
744
  const shadowDatabase = `${templateDatabase}__migration_shadow`;
745
+ const createShadowConnection = (database: string) =>
746
+ createKnexInstance({
747
+ ...Sonamu.dbConfig[shadowTarget],
748
+ connection: { ...baseTargetConn, database },
749
+ });
671
750
 
672
751
  // 테스트 상황에서는 트랜잭션을 초기화하고, 새 데이터베이스 커넥션을 가져와야 함
673
752
  if (isTest()) {
@@ -676,33 +755,23 @@ export class Migrator {
676
755
  }
677
756
 
678
757
  // 기존 Shadow DB 삭제 후 Shadow DB 생성
679
- const tdb = createKnexInstance({
680
- ...Sonamu.dbConfig.test,
681
- connection: {
682
- ...baseTestConn,
683
- database: "postgres",
684
- },
685
- });
758
+ const tdb = createShadowConnection("postgres");
686
759
  try {
687
760
  !isTest() && console.log(chalk.magenta(`${shadowDatabase} 삭제`));
688
- await tdb.raw(`DROP DATABASE IF EXISTS ${shadowDatabase}`);
689
- await tdb.raw(`
761
+ await tdb.raw("DROP DATABASE IF EXISTS ??", [shadowDatabase]);
762
+ await tdb.raw(
763
+ `
690
764
  SELECT pg_terminate_backend(pg_stat_activity.pid)
691
765
  FROM pg_stat_activity
692
- WHERE datname = '${tdbConn.database}'
766
+ WHERE datname = ?
693
767
  AND pid <> pg_backend_pid();
694
- `);
695
- await tdb.raw(`CREATE DATABASE ${shadowDatabase} TEMPLATE ${tdbConn.database}`);
768
+ `,
769
+ [templateDatabase],
770
+ );
771
+ await tdb.raw("CREATE DATABASE ?? TEMPLATE ??", [shadowDatabase, templateDatabase]);
696
772
 
697
773
  // Shadow DB에 연결
698
- const sdb = createKnexInstance({
699
- ...Sonamu.dbConfig.test,
700
- connection: {
701
- ...tdbConn,
702
- database: shadowDatabase,
703
- password: tdbConn.password,
704
- },
705
- });
774
+ const sdb = createShadowConnection(shadowDatabase);
706
775
 
707
776
  // shadow DB 테스트 진행
708
777
  try {
@@ -742,7 +811,7 @@ export class Migrator {
742
811
  // Shadow DB 삭제
743
812
  !isTest() && console.log(chalk.magenta(`${shadowDatabase} 삭제`));
744
813
  try {
745
- await tdb.raw(`DROP DATABASE IF EXISTS ${shadowDatabase}`);
814
+ await tdb.raw("DROP DATABASE IF EXISTS ??", [shadowDatabase]);
746
815
  } catch (e) {
747
816
  console.error("Shadow DB 정리 실패:", e); // 이게 없으면 조용히 누수
748
817
  } finally {
@@ -1,8 +1,8 @@
1
1
  import assert from "assert";
2
2
  import { readFile } from "fs/promises";
3
3
 
4
+ import ts from "@typescript/typescript6";
4
5
  import inflection from "inflection";
5
- import ts from "typescript";
6
6
 
7
7
  import { registeredApis } from "../api/decorators";
8
8
  import { type ExtendedApi } from "../api/decorators";