sonamu 0.9.4 → 0.9.6

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 (171) hide show
  1. package/dist/ai/providers/rtzr/utils.js +2 -2
  2. package/dist/api/config.d.ts +13 -2
  3. package/dist/api/config.d.ts.map +1 -1
  4. package/dist/api/config.js +1 -1
  5. package/dist/api/context.d.ts +17 -7
  6. package/dist/api/context.d.ts.map +1 -1
  7. package/dist/api/context.js +1 -1
  8. package/dist/api/decorators.d.ts +18 -0
  9. package/dist/api/decorators.d.ts.map +1 -1
  10. package/dist/api/decorators.js +54 -3
  11. package/dist/api/index.js +8 -3
  12. package/dist/api/sonamu.d.ts +24 -9
  13. package/dist/api/sonamu.d.ts.map +1 -1
  14. package/dist/api/sonamu.js +365 -79
  15. package/dist/api/websocket-helpers.d.ts +24 -0
  16. package/dist/api/websocket-helpers.d.ts.map +1 -0
  17. package/dist/api/websocket-helpers.js +77 -0
  18. package/dist/bin/cli.js +12 -4
  19. package/dist/database/upsert-builder.js +4 -4
  20. package/dist/dict/sonamu-dictionary.js +6 -6
  21. package/dist/entity/entity-manager.js +1 -1
  22. package/dist/entity/entity.js +3 -3
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -4
  26. package/dist/migration/code-generation.d.ts.map +1 -1
  27. package/dist/migration/code-generation.js +8 -9
  28. package/dist/stream/index.d.ts +6 -0
  29. package/dist/stream/index.d.ts.map +1 -1
  30. package/dist/stream/index.js +13 -2
  31. package/dist/stream/ws-audience-resolver.d.ts +15 -0
  32. package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
  33. package/dist/stream/ws-audience-resolver.js +31 -0
  34. package/dist/stream/ws-audience.d.ts +28 -0
  35. package/dist/stream/ws-audience.d.ts.map +1 -0
  36. package/dist/stream/ws-audience.js +46 -0
  37. package/dist/stream/ws-cluster-bus.d.ts +23 -0
  38. package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
  39. package/dist/stream/ws-cluster-bus.js +18 -0
  40. package/dist/stream/ws-core.d.ts +15 -0
  41. package/dist/stream/ws-core.d.ts.map +1 -0
  42. package/dist/stream/ws-core.js +1 -0
  43. package/dist/stream/ws-delivery.d.ts +24 -0
  44. package/dist/stream/ws-delivery.d.ts.map +1 -0
  45. package/dist/stream/ws-delivery.js +103 -0
  46. package/dist/stream/ws-local-connection-store.d.ts +10 -0
  47. package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
  48. package/dist/stream/ws-local-connection-store.js +44 -0
  49. package/dist/stream/ws-presence-store.d.ts +61 -0
  50. package/dist/stream/ws-presence-store.d.ts.map +1 -0
  51. package/dist/stream/ws-presence-store.js +236 -0
  52. package/dist/stream/ws-registry.d.ts +42 -0
  53. package/dist/stream/ws-registry.d.ts.map +1 -0
  54. package/dist/stream/ws-registry.js +108 -0
  55. package/dist/stream/ws.d.ts +52 -0
  56. package/dist/stream/ws.d.ts.map +1 -0
  57. package/dist/stream/ws.js +397 -0
  58. package/dist/syncer/api-parser.d.ts.map +1 -1
  59. package/dist/syncer/api-parser.js +72 -2
  60. package/dist/syncer/checksum.d.ts.map +1 -1
  61. package/dist/syncer/checksum.js +13 -12
  62. package/dist/syncer/code-generator.d.ts.map +1 -1
  63. package/dist/syncer/code-generator.js +7 -4
  64. package/dist/syncer/event-batcher.d.ts +27 -0
  65. package/dist/syncer/event-batcher.d.ts.map +1 -0
  66. package/dist/syncer/event-batcher.js +69 -0
  67. package/dist/syncer/file-patterns.d.ts +48 -26
  68. package/dist/syncer/file-patterns.d.ts.map +1 -1
  69. package/dist/syncer/file-patterns.js +71 -23
  70. package/dist/syncer/file-tracking.d.ts +13 -0
  71. package/dist/syncer/file-tracking.d.ts.map +1 -0
  72. package/dist/syncer/file-tracking.js +33 -0
  73. package/dist/syncer/index.js +2 -2
  74. package/dist/syncer/module-loader.d.ts +2 -11
  75. package/dist/syncer/module-loader.d.ts.map +1 -1
  76. package/dist/syncer/module-loader.js +3 -3
  77. package/dist/syncer/syncer-actions.d.ts +39 -6
  78. package/dist/syncer/syncer-actions.d.ts.map +1 -1
  79. package/dist/syncer/syncer-actions.js +125 -10
  80. package/dist/syncer/syncer.d.ts +33 -19
  81. package/dist/syncer/syncer.d.ts.map +1 -1
  82. package/dist/syncer/syncer.js +168 -168
  83. package/dist/syncer/watcher.d.ts +8 -0
  84. package/dist/syncer/watcher.d.ts.map +1 -0
  85. package/dist/syncer/watcher.js +105 -0
  86. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  87. package/dist/tasks/workflow-manager.js +2 -1
  88. package/dist/template/implementations/services.template.d.ts.map +1 -1
  89. package/dist/template/implementations/services.template.js +36 -1
  90. package/dist/testing/bootstrap.d.ts.map +1 -1
  91. package/dist/testing/bootstrap.js +8 -1
  92. package/dist/testing/data-explorer.d.ts.map +1 -1
  93. package/dist/testing/data-explorer.js +5 -3
  94. package/dist/testing/fixture-manager.js +1 -1
  95. package/dist/types/types.d.ts +2 -1
  96. package/dist/types/types.d.ts.map +1 -1
  97. package/dist/types/types.js +2 -2
  98. package/dist/ui/api.d.ts.map +1 -1
  99. package/dist/ui/api.js +4 -3
  100. package/dist/ui/cdd-service.js +1 -1
  101. package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
  102. package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
  103. package/dist/ui-web/index.html +2 -2
  104. package/dist/utils/async-utils.d.ts +27 -3
  105. package/dist/utils/async-utils.d.ts.map +1 -1
  106. package/dist/utils/async-utils.js +56 -6
  107. package/dist/utils/formatter.d.ts +7 -1
  108. package/dist/utils/formatter.d.ts.map +1 -1
  109. package/dist/utils/formatter.js +95 -60
  110. package/dist/utils/fs-utils.d.ts +2 -0
  111. package/dist/utils/fs-utils.d.ts.map +1 -1
  112. package/dist/utils/fs-utils.js +10 -2
  113. package/dist/utils/process-utils.d.ts +6 -0
  114. package/dist/utils/process-utils.d.ts.map +1 -1
  115. package/dist/utils/process-utils.js +16 -3
  116. package/dist/utils/utils.d.ts +1 -0
  117. package/dist/utils/utils.d.ts.map +1 -1
  118. package/dist/utils/utils.js +2 -2
  119. package/package.json +7 -5
  120. package/src/ai/providers/rtzr/utils.ts +1 -1
  121. package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
  122. package/src/api/__tests__/websocket-context.types.test.ts +58 -0
  123. package/src/api/config.ts +28 -2
  124. package/src/api/context.ts +21 -7
  125. package/src/api/decorators.ts +101 -1
  126. package/src/api/sonamu.ts +529 -127
  127. package/src/api/websocket-helpers.ts +122 -0
  128. package/src/bin/cli.ts +10 -2
  129. package/src/database/upsert-builder.ts +3 -3
  130. package/src/dict/sonamu-dictionary.ts +3 -3
  131. package/src/entity/entity.ts +1 -1
  132. package/src/index.ts +6 -0
  133. package/src/migration/code-generation.ts +6 -11
  134. package/src/shared/app.shared.ts.txt +312 -4
  135. package/src/shared/web.shared.ts.txt +340 -4
  136. package/src/stream/__tests__/ws-contracts.test.ts +381 -0
  137. package/src/stream/__tests__/ws.test.ts +449 -0
  138. package/src/stream/index.ts +6 -0
  139. package/src/stream/ws-audience-resolver.ts +35 -0
  140. package/src/stream/ws-audience.ts +62 -0
  141. package/src/stream/ws-cluster-bus.ts +32 -0
  142. package/src/stream/ws-core.ts +16 -0
  143. package/src/stream/ws-delivery.ts +138 -0
  144. package/src/stream/ws-local-connection-store.ts +44 -0
  145. package/src/stream/ws-presence-store.ts +326 -0
  146. package/src/stream/ws-registry.ts +138 -0
  147. package/src/stream/ws.ts +591 -0
  148. package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
  149. package/src/syncer/api-parser.ts +112 -1
  150. package/src/syncer/checksum.ts +23 -29
  151. package/src/syncer/code-generator.ts +4 -1
  152. package/src/syncer/event-batcher.ts +72 -0
  153. package/src/syncer/file-patterns.ts +98 -30
  154. package/src/syncer/file-tracking.ts +27 -0
  155. package/src/syncer/module-loader.ts +5 -12
  156. package/src/syncer/syncer-actions.ts +179 -17
  157. package/src/syncer/syncer.ts +250 -287
  158. package/src/syncer/watcher.ts +128 -0
  159. package/src/tasks/workflow-manager.ts +1 -0
  160. package/src/template/__tests__/services.template.websocket.test.ts +79 -0
  161. package/src/template/implementations/services.template.ts +69 -0
  162. package/src/testing/bootstrap.ts +8 -1
  163. package/src/testing/data-explorer.ts +3 -2
  164. package/src/types/types.ts +20 -2
  165. package/src/ui/api.ts +10 -1
  166. package/src/utils/async-utils.ts +71 -4
  167. package/src/utils/formatter.ts +114 -75
  168. package/src/utils/fs-utils.ts +9 -0
  169. package/src/utils/process-utils.ts +17 -0
  170. package/src/utils/utils.ts +1 -1
  171. package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
@@ -6,10 +6,24 @@ import ts from "typescript";
6
6
 
7
7
  import { registeredApis } from "../api/decorators";
8
8
  import { type ExtendedApi } from "../api/decorators";
9
+ import { type ResolvedWebSocketDecoratorOptions } from "../api/decorators";
9
10
  import { validateMethodName } from "../api/validator";
10
11
  import { type ApiParam, type ApiParamType } from "../types/types";
11
12
  import { type AbsolutePath } from "../utils/path-utils";
12
13
 
14
+ type WebSocketTypeRefs = Pick<
15
+ ResolvedWebSocketDecoratorOptions,
16
+ "outEventsTypeRef" | "inEventsTypeRef"
17
+ >;
18
+ type ParsedMethod = {
19
+ modelName: string;
20
+ methodName: string;
21
+ typeParameters: ApiParamType.TypeParam[];
22
+ parameters: ApiParam[];
23
+ returnType: ApiParamType;
24
+ websocketTypeRefs: WebSocketTypeRefs;
25
+ };
26
+
13
27
  /**
14
28
  * TypeScript 파일을 파싱하여 API 메소드 정보를 추출합니다.
15
29
  * @api 데코레이터가 붙은 메소드들의 타입 정보를 분석합니다.
@@ -29,7 +43,7 @@ export async function readApisFromFile(filePath: AbsolutePath): Promise<Extended
29
43
  ts.ScriptTarget.Latest,
30
44
  );
31
45
 
32
- const methods: Omit<ExtendedApi, "path" | "options">[] = [];
46
+ const methods: ParsedMethod[] = [];
33
47
  let modelName: string = "UnknownModel";
34
48
  let methodName: string = "unknownMethod";
35
49
  const visitor = (node: ts.Node) => {
@@ -74,6 +88,7 @@ export async function readApisFromFile(filePath: AbsolutePath): Promise<Extended
74
88
  throw new Error(`리턴 타입이 기재되지 않은 메소드 ${modelName}.${methodName}`);
75
89
  }
76
90
  const returnType = resolveTypeNode(node.type);
91
+ const websocketTypeRefs = readWebSocketTypeRefs(node);
77
92
 
78
93
  methods.push({
79
94
  modelName,
@@ -81,6 +96,7 @@ export async function readApisFromFile(filePath: AbsolutePath): Promise<Extended
81
96
  typeParameters,
82
97
  parameters,
83
98
  returnType,
99
+ websocketTypeRefs,
84
100
  });
85
101
  }
86
102
  ts.forEachChild(node, visitor);
@@ -113,8 +129,16 @@ export async function readApisFromFile(filePath: AbsolutePath): Promise<Extended
113
129
  if (!foundMethod) {
114
130
  throw new Error(`API ${api.modelName}.${api.methodName} not found in ${filePath}`);
115
131
  }
132
+ const websocketOptions = api.websocketOptions
133
+ ? {
134
+ ...api.websocketOptions,
135
+ ...foundMethod.websocketTypeRefs,
136
+ }
137
+ : undefined;
138
+
116
139
  return {
117
140
  ...api,
141
+ websocketOptions,
118
142
  typeParameters: foundMethod?.typeParameters,
119
143
  parameters: foundMethod?.parameters,
120
144
  returnType: foundMethod?.returnType,
@@ -123,6 +147,93 @@ export async function readApisFromFile(filePath: AbsolutePath): Promise<Extended
123
147
  return extendedApis;
124
148
  }
125
149
 
150
+ function readWebSocketTypeRefs(node: ts.MethodDeclaration): WebSocketTypeRefs {
151
+ const optionsLiteral = getDecoratorOptionsObjectLiteral(node, "websocket");
152
+ if (!optionsLiteral) {
153
+ return {};
154
+ }
155
+
156
+ const refs: WebSocketTypeRefs = {};
157
+ for (const property of optionsLiteral.properties) {
158
+ if (!ts.isPropertyAssignment(property)) {
159
+ continue;
160
+ }
161
+
162
+ const propertyName = getPropertyNameText(property.name);
163
+ if (propertyName !== "outEvents" && propertyName !== "inEvents") {
164
+ continue;
165
+ }
166
+
167
+ const typeRef = resolveDecoratorTypeRef(property.initializer);
168
+ if (!typeRef) {
169
+ continue;
170
+ }
171
+
172
+ if (propertyName === "outEvents") {
173
+ refs.outEventsTypeRef = typeRef;
174
+ } else {
175
+ refs.inEventsTypeRef = typeRef;
176
+ }
177
+ }
178
+
179
+ return refs;
180
+ }
181
+
182
+ function getDecoratorOptionsObjectLiteral(
183
+ node: ts.MethodDeclaration,
184
+ decoratorName: string,
185
+ ): ts.ObjectLiteralExpression | undefined {
186
+ for (const modifier of node.modifiers ?? []) {
187
+ if (!ts.isDecorator(modifier)) {
188
+ continue;
189
+ }
190
+
191
+ const expression = modifier.expression;
192
+ if (!ts.isCallExpression(expression)) {
193
+ continue;
194
+ }
195
+
196
+ if (!ts.isIdentifier(expression.expression) || expression.expression.text !== decoratorName) {
197
+ continue;
198
+ }
199
+
200
+ const [firstArg] = expression.arguments;
201
+ if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
202
+ return firstArg;
203
+ }
204
+ }
205
+
206
+ return undefined;
207
+ }
208
+
209
+ function getPropertyNameText(name: ts.PropertyName): string | undefined {
210
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
211
+ return name.text;
212
+ }
213
+
214
+ return undefined;
215
+ }
216
+
217
+ function resolveDecoratorTypeRef(expression: ts.Expression): ApiParamType.Ref | undefined {
218
+ if (ts.isIdentifier(expression)) {
219
+ return {
220
+ t: "ref",
221
+ id: expression.text,
222
+ };
223
+ }
224
+
225
+ if (
226
+ ts.isAsExpression(expression) ||
227
+ ts.isParenthesizedExpression(expression) ||
228
+ ts.isNonNullExpression(expression) ||
229
+ ts.isTypeAssertionExpression(expression)
230
+ ) {
231
+ return resolveDecoratorTypeRef(expression.expression);
232
+ }
233
+
234
+ return undefined;
235
+ }
236
+
126
237
  function resolveTypeNode(typeNode: ts.TypeNode): ApiParamType {
127
238
  switch (typeNode?.kind) {
128
239
  case ts.SyntaxKind.AnyKeyword:
@@ -1,7 +1,5 @@
1
- import crypto from "crypto";
2
- import { type BinaryLike } from "crypto";
3
- import { createReadStream } from "fs";
4
- import { type PathLike } from "fs";
1
+ import crypto, { type BinaryLike } from "crypto";
2
+ import { createReadStream, type PathLike } from "fs";
5
3
  import { readFile, writeFile } from "fs/promises";
6
4
  import path from "path";
7
5
 
@@ -11,9 +9,9 @@ import { isEqual } from "radashi";
11
9
  import { Sonamu } from "../api/sonamu";
12
10
  import { globAsync } from "../utils/async-utils";
13
11
  import { exists } from "../utils/fs-utils";
14
- import { type AbsolutePath, type ApiRelativePath } from "../utils/path-utils";
12
+ import { type AbsolutePath, type AppRelativePath } from "../utils/path-utils";
15
13
  import { differenceWith } from "../utils/utils";
16
- import { getChecksumPatternGroupInAbsolutePath } from "./file-patterns";
14
+ import { getChecksumPatternGroupInAbsolutePath, GLOB_EXCLUDE } from "./file-patterns";
17
15
 
18
16
  type PathAndChecksum = {
19
17
  path: AbsolutePath;
@@ -53,17 +51,19 @@ export async function renewChecksums(): Promise<void> {
53
51
  }
54
52
 
55
53
  async function getCurrentChecksums(): Promise<PathAndChecksum[]> {
56
- const filePaths = (
54
+ const allPaths = (
57
55
  await Promise.all(
58
- Object.entries(getChecksumPatternGroupInAbsolutePath()).map(async ([_fileType, pattern]) => {
59
- return globAsync(pattern) as Promise<AbsolutePath[]>;
56
+ Object.entries(getChecksumPatternGroupInAbsolutePath()).map(([_fileType, pattern]) => {
57
+ return globAsync(pattern, { exclude: GLOB_EXCLUDE });
60
58
  }),
61
59
  )
62
- )
63
- .flat()
64
- .toSorted();
60
+ ).flat();
65
61
 
66
- const fileChecksums = await Promise.all(
62
+ // 동일 파일이 여러 패턴에 매치될 수 있으므로(예: sd.generated.ts는 generated와 sdGenerated에 모두 매치)
63
+ // 중복 제거 후 안정 정렬.
64
+ const filePaths = Array.from(new Set(allPaths)).toSorted() as AbsolutePath[];
65
+
66
+ return await Promise.all(
67
67
  filePaths.map(async (filePath) => {
68
68
  return {
69
69
  path: filePath,
@@ -71,8 +71,6 @@ async function getCurrentChecksums(): Promise<PathAndChecksum[]> {
71
71
  };
72
72
  }),
73
73
  );
74
-
75
- return fileChecksums;
76
74
  }
77
75
 
78
76
  async function getPreviousChecksums(): Promise<PathAndChecksum[]> {
@@ -83,8 +81,8 @@ async function getPreviousChecksums(): Promise<PathAndChecksum[]> {
83
81
 
84
82
  try {
85
83
  const previousChecksums = JSON.parse(await readFile(checksumFilePath, "utf-8")).map(
86
- (r: { path: ApiRelativePath; checksum: string }) => ({
87
- path: path.join(Sonamu.apiRootPath, r.path), // 체크섬 파일에서 읽을 때: API 상대 경로 → 절대 경로
84
+ (r: { path: AppRelativePath; checksum: string }) => ({
85
+ path: path.join(Sonamu.appRootPath, r.path), // 체크섬 파일에서 읽을 때: appRoot 상대 경로 → 절대 경로
88
86
  checksum: r.checksum,
89
87
  }),
90
88
  ) as PathAndChecksum[];
@@ -105,18 +103,14 @@ function getChecksumFilePath(): AbsolutePath {
105
103
 
106
104
  async function saveChecksums(checksums: PathAndChecksum[]): Promise<void> {
107
105
  const checksumFilePath = getChecksumFilePath();
108
- await writeFile(
109
- checksumFilePath,
110
- JSON.stringify(
111
- checksums.map((r) => ({
112
- path: path.relative(Sonamu.apiRootPath, r.path), // 체크섬 파일에 저장할 때: 절대 경로 → API 상대 경로
113
- checksum: r.checksum,
114
- })),
115
- null,
116
- 2,
117
- ),
118
- "utf-8",
119
- );
106
+ // appRoot 상대 경로로 직렬화 + 알파벳 안정 정렬 (PR diff 깨끗하게 유지)
107
+ const serialized = checksums
108
+ .map((r) => ({
109
+ path: path.relative(Sonamu.appRootPath, r.path), // 체크섬 파일에 저장할 때: 절대 경로 → appRoot 상대 경로
110
+ checksum: r.checksum,
111
+ }))
112
+ .sort((a, b) => a.path.localeCompare(b.path));
113
+ await writeFile(checksumFilePath, JSON.stringify(serialized, null, 2), "utf-8");
120
114
  }
121
115
 
122
116
  async function getChecksumOfFile(filePath: PathLike): Promise<string> {
@@ -24,6 +24,7 @@ import { formatCode } from "../utils/formatter";
24
24
  import { exists } from "../utils/fs-utils";
25
25
  import { wrapIf } from "../utils/lodash-able";
26
26
  import { type AbsolutePath } from "../utils/path-utils";
27
+ import { trackWritten } from "./file-tracking";
27
28
 
28
29
  /**
29
30
  * 템플릿을 렌더링하고 파일로 생성합니다.
@@ -179,7 +180,6 @@ async function resolveRenderedTemplate(
179
180
  Naite.t("resolveRenderedTemplate:beforeFormat", { key, header, body });
180
181
  const formatted = await formatCode(
181
182
  [header, body].join("\n\n"),
182
- key === "entity" ? "json" : "typescript",
183
183
  `${Sonamu.appRootPath}/${filePath}`,
184
184
  );
185
185
  Naite.t(`resolveRenderedTemplate:formatted:${key}`, formatted);
@@ -208,6 +208,9 @@ async function writeCodeToPathEachTarget(pathAndCode: PathAndCode): Promise<Abso
208
208
  await mkdir(dir, { recursive: true });
209
209
  }
210
210
  await writeFile(dstFilePath, pathAndCode.code);
211
+ // 방금 우리가 쓴 path를 등록 → dev watcher의 후속 change 이벤트가
212
+ // 외부 변경으로 오인되지 않도록 거름 가드 자료 제공.
213
+ await trackWritten(dstFilePath);
211
214
  !isTest() &&
212
215
  console.log(
213
216
  chalk.bold("Generated: ") + chalk.blue(`${dstFilePath.replace(`${appRootPath}/`, "")}`),
@@ -0,0 +1,72 @@
1
+ import { type AbsolutePath } from "../utils/path-utils";
2
+
3
+ /**
4
+ * 파일 변경 이벤트를 batch로 모아 한 번에 처리하는 throttler를 만듭니다.
5
+ *
6
+ * 동작:
7
+ * - 같은 path가 여러 번 push되면 마지막 event로 dedupe됩니다.
8
+ * - 마지막 push 후 `delayMs` 동안 추가 push가 없으면 한 번에 flush됩니다 (trailing debounce).
9
+ * - flush 도중 들어오는 push는 다음 batch로 큐잉됩니다.
10
+ * - 한 시점에 onFlush는 단 하나만 진행됩니다 — 이로써 onFlush 내부에서 일어나는
11
+ * 작업이 다른 onFlush 호출과 인터리브되지 않음을 보장합니다.
12
+ *
13
+ * 호출자는 push만 하면 됩니다. 내부 큐/타이머 상태는 캡슐화되어 있고, onFlush가
14
+ * 받는 fileEvents는 dedupe된 Map입니다.
15
+ *
16
+ * @example
17
+ * const push = createFileEventBatcher({
18
+ * delayMs: 100,
19
+ * onFlush: (fileEvents) => handleBatch(fileEvents),
20
+ * });
21
+ * push("/path/a.ts" as AbsolutePath, "change");
22
+ * push("/path/b.ts" as AbsolutePath, "change"); // 100ms 후 둘 다 한 번에 onFlush로
23
+ */
24
+ export function createFileEventBatcher<FileEventT extends string>(options: {
25
+ delayMs: number;
26
+ onFlush: (fileEvents: Map<AbsolutePath, FileEventT>) => Promise<void>;
27
+ }): (path: AbsolutePath, event: FileEventT) => void {
28
+ const { delayMs, onFlush } = options;
29
+ const pending = new Map<AbsolutePath, FileEventT>();
30
+ let flushTimer: NodeJS.Timeout | null = null;
31
+ let isFlushing = false;
32
+
33
+ const scheduleFlush = (): void => {
34
+ if (flushTimer !== null) {
35
+ clearTimeout(flushTimer);
36
+ }
37
+ flushTimer = setTimeout(() => {
38
+ flushTimer = null;
39
+ void runFlush();
40
+ }, delayMs);
41
+ };
42
+
43
+ const runFlush = async (): Promise<void> => {
44
+ // flush 도중 추가 push로 또 다른 runFlush가 트리거된 경우, 한 번에 하나만 진행되게 합니다.
45
+ // 진행 중인 flush가 끝나면 finally에서 다음 batch를 자동으로 다시 스케줄합니다.
46
+ if (isFlushing || pending.size === 0) {
47
+ return;
48
+ }
49
+ isFlushing = true;
50
+ try {
51
+ // 현재까지 모인 변경을 snapshot으로 잡고 큐는 비웁니다.
52
+ // 이 batch가 처리되는 동안 들어오는 새 변경은 다음 batch로 모입니다.
53
+ const batch = new Map(pending);
54
+ pending.clear();
55
+ try {
56
+ await onFlush(batch);
57
+ } catch (e) {
58
+ console.error(e);
59
+ }
60
+ } finally {
61
+ isFlushing = false;
62
+ if (pending.size > 0) {
63
+ scheduleFlush();
64
+ }
65
+ }
66
+ };
67
+
68
+ return (path, event) => {
69
+ pending.set(path, event);
70
+ scheduleFlush();
71
+ };
72
+ }
@@ -1,51 +1,119 @@
1
1
  import path from "path";
2
2
 
3
3
  import { Sonamu } from "../api/sonamu";
4
- import { type AbsolutePath, type ApiRelativePath } from "../utils/path-utils";
4
+ import { type AbsolutePath, type AppRelativePath } from "../utils/path-utils";
5
5
 
6
6
  /**
7
7
  * Syncer가 관심 가지고 지켜보는 파일들입니다.
8
8
  * 이 파일들에 변경이 생기면 추가적인 작업(이하 "싱크" 또는 "싱크 액션")을 수행합니다.
9
9
  * 이 작업이라 함은 파일 복사 또는 템플릿 렌더링을 통한 code generation을 의미합니다.
10
10
  *
11
- * **경로 형식**: API 상대 경로 (src/로 시작)
12
- * **사용**: getChecksumPatternGroupInAbsolutePath()로 절대 경로 변환 후 glob 사용
11
+ * 경로 형식: appRoot 기준 상대 경로 (target 디렉토리로 시작, 예: "api/src/...", "web/src/...")
12
+ * 사용: getChecksumPatternGroupInAbsolutePath()로 절대 경로 변환 후 glob 사용
13
+ *
14
+ * 두 가지 의미적 영역:
15
+ * - 입력 (사용자 작성): api 디렉토리에만 위치. 사용자가 직접 편집.
16
+ * - 출력 (sonamu 생성/복사): api 또는 target 디렉토리에 sonamu가 만들어내는 파일.
17
+ *
18
+ * 추적 밖 자산 (부트스트랩 phase에서 매번 보장):
19
+ * - sonamu.shared.ts: 사용자가 커스터마이즈하는 자산. IfNotExists로 1회 생성 후 손대지 않음.
20
+ * - entry-server.generated.tsx: 입력 의존 없는 정적 코드. 매번 overwrite generate.
21
+ *
22
+ * 위 둘은 sync()의 부트스트랩 phase에서 변경 검출 사이클과 무관하게 보장됩니다.
23
+ * 추적 사이클 안에서 할 액션이 없는 자산이라 패턴 그룹에 포함되지 않습니다.
24
+ *
25
+ * 위치 카테고리는 api/targets/anywhere 헬퍼로 명시적으로 표현합니다.
26
+ *
27
+ * FileType은 이 함수의 반환 타입에서 자동 추론됩니다. 키 추가 시 별도 enum/배열을
28
+ * 동기화할 필요 없이 여기 한 군데만 수정하면 됩니다.
13
29
  */
14
- const checksumPatternGroup = {
15
- config: "src/sonamu.config.ts",
16
- entity: "src/application/**/*.entity.json",
17
- frame: "src/application/**/*.frame.ts",
18
- functions: "src/application/**/*.functions.ts",
19
- generated: "src/application/sonamu.generated.ts",
20
- model: "src/application/**/*.model.ts",
21
- types: "src/application/**/*.types.ts",
22
- workflow: "src/application/**/*.workflow.ts",
23
- i18n: "src/i18n/**/!(sd.generated).ts",
24
- i18nGenerated: "src/i18n/**/sd.generated.ts",
25
- } as const satisfies Record<string, ApiRelativePath>;
26
-
27
- export { checksumPatternGroup };
28
- export type FileType = keyof typeof checksumPatternGroup;
29
- export type GlobPattern<T extends ApiRelativePath | AbsolutePath> = Record<FileType, T>;
30
+ export function getChecksumPatternGroup() {
31
+ // 헬퍼 함수들 만들어서 가져옵니다.
32
+ const { api, targets, anywhere } = globBuilders();
33
+
34
+ return {
35
+ // Sonamu 입장에서 soruce가 되는 파일들입니다.
36
+ // 이 친구들이 변경되면 이들로부터 액션을 수행하고 파일을 생성합니다.
37
+ // 모노리포에서 이 source들은 api 프로젝트에 한정되므로, api에 있는 친구들만 리스팅합니다.
38
+ config: api("src/sonamu.config.ts"),
39
+ entity: api("src/application/**/*.entity.json"),
40
+ frame: api("src/application/**/*.frame.ts"),
41
+ functions: api("src/application/**/*.functions.ts"),
42
+ model: api("src/application/**/*.model.ts"),
43
+ types: api("src/application/**/*.types.ts"),
44
+ workflow: api("src/application/**/*.workflow.ts"),
45
+ i18n: api("src/i18n/**/!(sd.generated).ts"),
46
+
47
+ // Sonamu가 출력하는 생성 파일들입니다.
48
+ // 이 친구들도 정합성 검증 차원에서 sonamu.lock에 기록해야 하고,
49
+ // 또한 변경시 그 사실을 syncer가 알기는 해야 합니다(비록 별다른 처리가 없는 경우도 있지만).
50
+ //
51
+ // 자산 본성에 따라 위치 카테고리가 다르기 때문에, 본성별로 분리해서 표기합니다.
52
+ // - 양쪽-필요 자산: api에 정본이 만들어진 뒤 target에 복사됨 (sonamu.generated.*, queries.generated.ts).
53
+ // - api 전용 자산: api에만 만들어짐 (sonamu.generated.http).
54
+ // - target 전용 자산: target에만 만들어짐 (services.generated.ts는 services.template의 :target 분배).
55
+ //
56
+ // 여기에는 Sonamu의 모든 sync 산출물이 있는 것은 아닙니다.
57
+ // sonamu.shared.ts와 entry-server.generated.tsx와 같은
58
+ // sync 초반 1회성 부트스트랩 파일들은 관리 안 하기 때문에 여기에 리스팅도 안 합니다.
59
+ generated: api("src/application/**/*.generated.{ts,tsx,sso.ts}"),
60
+ generatedCopied: targets("src/services/**/{sonamu,queries}.generated.{ts,tsx,sso.ts}"),
61
+ httpGenerated: api("src/application/**/*.generated.http"),
62
+ servicesGenerated: targets("src/services/services.generated.ts"),
63
+ sdGenerated: anywhere("src/i18n/**/sd.generated.ts"),
64
+ typesCopied: targets("src/services/**/*.types.ts"),
65
+ functionsCopied: targets("src/services/**/*.functions.ts"),
66
+ i18nCopied: targets("src/i18n/**/!(sd.generated).ts"),
67
+ } satisfies Record<string, AppRelativePath>;
68
+ }
30
69
 
31
70
  /**
32
- * API 상대 경로 패턴을 절대 경로 패턴으로 변환합니다.
33
- *
34
- * **목적**: Glob 패턴을 파일시스템에서 사용할 있는 절대 경로로 변환
35
- *
36
- * **사용처**: checksum.ts에서 실제 파일을 찾을 때
71
+ * 위치 카테고리별 글롭 빌더를 만들어 반환합니다.
72
+ * - api(rest): api 디렉토리에 한정
73
+ * - targets(rest): target 디렉토리들에 한정 (web, app 등)
74
+ * - anywhere(rest): api와 target 모두
75
+ */
76
+ function globBuilders() {
77
+ const apiDir = Sonamu.config.api.dir;
78
+ const targetDirs = Sonamu.config.sync.targets;
79
+
80
+ // Node 내장 fs.glob의 brace expansion은 단일 멤버 {x}를 풀지 않으므로, 멤버가 1개일 때는 alternation 없이 직접 결합합니다.
81
+ const braceJoin = (dirs: readonly string[]) =>
82
+ dirs.length === 1 ? dirs[0] : `{${dirs.join(",")}}`;
83
+
84
+ return {
85
+ api: (pathFromApi: string) => `${apiDir}/${pathFromApi}` as AppRelativePath,
86
+ targets: (pathFromTarget: string) =>
87
+ `${braceJoin(targetDirs)}/${pathFromTarget}` as AppRelativePath,
88
+ anywhere: (pathFromAnywhere: string) =>
89
+ `${braceJoin([apiDir, ...targetDirs])}/${pathFromAnywhere}` as AppRelativePath,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * FileType은 getChecksumPatternGroup의 반환 객체 키에서 자동 추론됩니다.
95
+ * 별도 배열/enum 동기화 불필요 — 패턴 그룹 함수가 진실의 단일 원천.
96
+ */
97
+ export type FileType = keyof ReturnType<typeof getChecksumPatternGroup>;
98
+ export type GlobPattern<T extends AppRelativePath | AbsolutePath> = Record<FileType, T>;
99
+
100
+ /**
101
+ * 빌드 산출물 디렉토리는 alternation 글롭이 의도치 않게 휘말릴 수 있으므로 안전망으로 제외.
102
+ * Node 내장 fs.glob의 exclude 옵션과 함께 사용합니다.
103
+ */
104
+ export const GLOB_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.turbo/**"];
105
+
106
+ /**
107
+ * appRoot 기준 상대 경로 패턴을 절대 경로 패턴으로 변환합니다.
37
108
  *
38
109
  * @returns 절대 경로 기반 Glob 패턴 맵
39
- *
40
- * @example
41
- * // 입력: { entity: "src/application/**\/*.entity.json" }
42
- * // 출력: { entity: "/Users/.../api/src/application/**\/*.entity.json" }
43
110
  */
44
111
  export function getChecksumPatternGroupInAbsolutePath(): GlobPattern<AbsolutePath> {
112
+ const group = getChecksumPatternGroup();
45
113
  return Object.fromEntries(
46
- Object.entries(checksumPatternGroup).map(([key, value]) => [
114
+ Object.entries(group).map(([key, value]) => [
47
115
  key,
48
- path.join(Sonamu.apiRootPath, value), // API 상대 경로 → 절대 경로
116
+ path.join(Sonamu.appRootPath, value), // appRoot 상대 경로 → 절대 경로
49
117
  ]),
50
118
  ) as GlobPattern<AbsolutePath>;
51
119
  }
@@ -0,0 +1,27 @@
1
+ import { stat } from "fs/promises";
2
+
3
+ import { type AbsolutePath } from "../utils/path-utils";
4
+
5
+ export const fileWrittenAt = new Map<AbsolutePath, number>();
6
+
7
+ /**
8
+ * 우리가 디스크에 write한 path를 등록합니다. write 직후 호출하세요.
9
+ * 디스크에서 mtime을 직접 읽어 정확한 값으로 등록합니다.
10
+ */
11
+ export async function trackWritten(filePath: AbsolutePath): Promise<void> {
12
+ const fileStat = await stat(filePath);
13
+ fileWrittenAt.set(filePath, fileStat.mtimeMs);
14
+ }
15
+
16
+ /**
17
+ * 주어진 path가 가장 최근 우리 write 이후로 외부에서 수정된 적 없는지 확인합니다.
18
+ * true면 "내가 쓴 그대로 남아있음", false면 "외부에서 수정됐거나 우리가 쓴 적 없음".
19
+ */
20
+ export async function isLastChangedByMe(filePath: AbsolutePath): Promise<boolean> {
21
+ const registered = fileWrittenAt.get(filePath);
22
+ if (registered === undefined) {
23
+ return false;
24
+ }
25
+ const fileStat = await stat(filePath);
26
+ return fileStat.mtimeMs <= registered;
27
+ }
@@ -3,26 +3,17 @@ import path from "path";
3
3
  import { z } from "zod";
4
4
 
5
5
  import { type BaseFrameClass } from "../api/base-frame";
6
- import { type ApiDecoratorOptions } from "../api/decorators";
6
+ import { type ExtendedApi } from "../api/decorators";
7
7
  import { Sonamu } from "../api/sonamu";
8
8
  import { type BaseModelClass } from "../database/base-model";
9
9
  import { type WorkflowMetadata } from "../tasks/decorator";
10
- import { type ApiParam, type ApiParamType } from "../types/types";
11
10
  import { globAsync } from "../utils/async-utils";
12
11
  import { importMembers } from "../utils/esm-utils";
13
12
  import { runtimePath } from "../utils/path-utils";
14
13
  import { type AbsolutePath } from "../utils/path-utils";
15
14
  import { readApisFromFile } from "./api-parser";
16
15
 
17
- export type LoadedApis = {
18
- typeParameters: ApiParamType.TypeParam[];
19
- parameters: ApiParam[];
20
- returnType: ApiParamType;
21
- modelName: string;
22
- methodName: string;
23
- path: string;
24
- options: ApiDecoratorOptions;
25
- }[];
16
+ export type LoadedApis = ExtendedApi[];
26
17
 
27
18
  export type LoadedTypes = { [typeName: string]: z.ZodType };
28
19
 
@@ -100,7 +91,9 @@ export async function loadTypes(): Promise<LoadedTypes> {
100
91
  path.join(Sonamu.apiRootPath, runtimePath("src/application/**/*.types.ts")),
101
92
  path.join(Sonamu.apiRootPath, runtimePath("src/application/**/*.generated.ts")),
102
93
  ];
103
- const typePaths = (await Promise.all(typePathsPatterns.map(globAsync))).flat();
94
+ const typePaths = (
95
+ await Promise.all(typePathsPatterns.map((pattern) => globAsync(pattern)))
96
+ ).flat();
104
97
 
105
98
  const types: LoadedTypes = {};
106
99
  for (const filePath of typePaths) {