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
@@ -0,0 +1,122 @@
1
+ import { type Server } from "http";
2
+
3
+ import { type WebsocketPluginOptions } from "@fastify/websocket";
4
+ import { type FastifyReply } from "fastify";
5
+
6
+ import { isSoException } from "../exceptions/so-exceptions";
7
+ import { isPlainObject } from "../utils/utils";
8
+ import { type ExtendedApi } from "./decorators";
9
+
10
+ // Fastify websocket route와 Vite HMR websocket이 같은 server socket을 두고 충돌하는 것을 방지하기 위해,
11
+ // WS route가 존재하면 HMR을 별도 포트로 분리해 띄움
12
+ export function resolveIntegratedViteHmrOptions({
13
+ httpServer,
14
+ requiresDedicatedWebSocketServer,
15
+ rawPort = process.env.SONAMU_VITE_HMR_PORT,
16
+ }: {
17
+ httpServer: Server;
18
+ requiresDedicatedWebSocketServer: boolean;
19
+ rawPort?: string | undefined;
20
+ }): { server: Server } | { port: number } {
21
+ if (!requiresDedicatedWebSocketServer) {
22
+ return { server: httpServer };
23
+ }
24
+
25
+ const parsedPort = rawPort?.trim() ? Number(rawPort) : 24678;
26
+ return Number.isFinite(parsedPort) && parsedPort > 0 ? { port: parsedPort } : { port: 24678 };
27
+ }
28
+
29
+ // route-level maxPayload를 서버 plugin options으로 승격시켜, 큰 frame을 받은 뒤 닫는 대신
30
+ // transport 레벨에서 먼저 제한되도록 함
31
+ export function resolveWebSocketPluginOptions({
32
+ rawPluginOption,
33
+ apis,
34
+ }: {
35
+ rawPluginOption: boolean | WebsocketPluginOptions | undefined;
36
+ apis: ExtendedApi[];
37
+ }): WebsocketPluginOptions | undefined {
38
+ const pluginOptions =
39
+ rawPluginOption && rawPluginOption !== true
40
+ ? { ...rawPluginOption }
41
+ : ({} as WebsocketPluginOptions & { maxPayload?: number });
42
+ const serverOptions = isPlainObject(pluginOptions.options)
43
+ ? { ...pluginOptions.options }
44
+ : ({} as NonNullable<WebsocketPluginOptions["options"]>);
45
+
46
+ if (isPositiveNumber(pluginOptions.maxPayload) && serverOptions.maxPayload === undefined) {
47
+ serverOptions.maxPayload = pluginOptions.maxPayload;
48
+ delete pluginOptions.maxPayload;
49
+ }
50
+
51
+ if (serverOptions.maxPayload === undefined) {
52
+ const routeMaxPayloads = apis
53
+ .map((api) => api.websocketOptions?.maxPayload)
54
+ .filter(isPositiveNumber);
55
+
56
+ if (routeMaxPayloads.length > 0) {
57
+ serverOptions.maxPayload = Math.max(...routeMaxPayloads);
58
+ }
59
+ }
60
+
61
+ if (Object.keys(serverOptions).length > 0) {
62
+ pluginOptions.options = serverOptions;
63
+ }
64
+
65
+ return Object.keys(pluginOptions).length > 0 ? pluginOptions : undefined;
66
+ }
67
+
68
+ // handshake/auth/validation 실패를 generic 1011로 뭉개지 않고 1008(policy violation)로 매핑해,
69
+ // close code policy를 한 곳에서 정의함
70
+ export function resolveWebSocketCloseDescriptor(error: unknown): {
71
+ code: number;
72
+ reason: string;
73
+ logLevel: "warn" | "error";
74
+ } {
75
+ if (isSoException(error)) {
76
+ if (error.statusCode === 400) {
77
+ return {
78
+ code: 1008,
79
+ reason: "Invalid websocket handshake",
80
+ logLevel: "warn",
81
+ };
82
+ }
83
+
84
+ if (error.statusCode === 401 || error.statusCode === 403) {
85
+ return {
86
+ code: 1008,
87
+ reason: "Unauthorized websocket connection",
88
+ logLevel: "warn",
89
+ };
90
+ }
91
+
92
+ if (error.statusCode >= 400 && error.statusCode < 500) {
93
+ return {
94
+ code: 1008,
95
+ reason: "Rejected websocket connection",
96
+ logLevel: "warn",
97
+ };
98
+ }
99
+ }
100
+
101
+ return {
102
+ code: 1011,
103
+ reason: "WebSocket handler failed",
104
+ logLevel: "error",
105
+ };
106
+ }
107
+
108
+ // WS 경로에서는 reply가 존재하지 않으므로 접근 시도를 즉시 에러로 surface해 transport misuse를 빨리 드러냄
109
+ // SSE/reply에 의존하는 contextProvider가 있으면 websocketContextProvider를 따로 정의하라는 가이드 역할도 함
110
+ export function createWebSocketReplyStub(): FastifyReply {
111
+ return new Proxy({} as FastifyReply, {
112
+ get() {
113
+ throw new Error(
114
+ "FastifyReply is not available in websocket context. Define websocketContextProvider if your context setup depends on reply mutation.",
115
+ );
116
+ },
117
+ });
118
+ }
119
+
120
+ function isPositiveNumber(value: unknown): value is number {
121
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
122
+ }
package/src/bin/cli.ts CHANGED
@@ -228,9 +228,17 @@ bootstrap().finally(async () => {
228
228
  /**
229
229
  * pnpm sync 하면 실행되는 함수입니다.
230
230
  * 프로젝트를 싱크합니다.
231
+ *
232
+ * `--force` 옵션이 주어지면 lock을 무시하고 풀-싱크를 수행합니다.
233
+ * git post-merge hook이나 CI에서 매 pull 후 자동 실행할 수 있도록 노출.
231
234
  */
232
235
  async function sync() {
233
- await Sonamu.syncer.sync();
236
+ const { flags } = parseCliOptions();
237
+ if (flags.has("force")) {
238
+ await Sonamu.syncer.forceSync();
239
+ } else {
240
+ await Sonamu.syncer.sync();
241
+ }
234
242
  }
235
243
 
236
244
  /**
@@ -265,7 +273,7 @@ function spawnApiDevServer(options?: { extraEnv?: Record<string, string> }) {
265
273
  "--node-args=--enable-source-maps", // 그리고 소스맵 지원을 위한 플래그입니다.
266
274
  "--on-key=r:restart:Restart server", // r 누르면 서버 재시작하게 해줘요.
267
275
  "--on-key=c:clear:Clear screen", // c 누르면 터미널 화면을 지워줘요.
268
- `--on-key=f:shell(rm ${path.join(apiRoot, "sonamu.lock")}):restart:Force restart`, // f 누르면 sonamu.lock 파일을 지우고 서버 재시작하게 해줘요.
276
+ `--on-key=f:shell(rm ${path.join(apiRoot, "sonamu.lock")}):restart:Force restart`, // f 누르면 lock 제거 재시작 새 프로세스가 부트스트랩에서 풀-싱크. force sync CLI를 shell로 부르면 살아있는 서버의 watcher 폭풍이 :restart와 충돌해 상태 이상.
269
277
 
270
278
  "--on-key=enter:shell(echo hi):Key binding test", // enter를 key로 쓸 수 있음을 보이기 위한 테스트입니다.
271
279
  "--on-key=ctrl+f ctrl+f:shell(git pull && pnpm install && pnpm --filter sonamu build && echo 'Sonamu is now up-to-date!'):restart:Pull & install & build & restart", // modifier와의 조합, 그리고 두 개의 chord를 사용할 수 있음을 보이기 위한 테스트입니다.
@@ -310,7 +310,7 @@ export class UpsertBuilder {
310
310
 
311
311
  // uuid를 별도로 보관하고, DB에 저장할 데이터에서 제거
312
312
  const originalUuids = dataChunk.map((r) => r.uuid as string);
313
- const dataForDb = dataChunk.map(({ uuid, ...rest }) => rest);
313
+ const dataForDb = dataChunk.map(({ uuid: _, ...rest }) => rest);
314
314
 
315
315
  let resultRows: { id: number | string; [key: string]: unknown }[];
316
316
 
@@ -331,7 +331,7 @@ export class UpsertBuilder {
331
331
  for (const row of rowsWithoutId) {
332
332
  const values = columns.map((col) => row[col]);
333
333
  // null이 포함된 조건은 제외 (PostgreSQL UNIQUE는 NULL 무시)
334
- if (!values.some((v) => v == null)) {
334
+ if (!values.some((v) => v === null || v === undefined)) {
335
335
  conditions.push(values);
336
336
  }
337
337
  }
@@ -448,7 +448,7 @@ export class UpsertBuilder {
448
448
 
449
449
  // 현재 register된 레코드들의 FK 값들 추출
450
450
  const fkConditions = fkColumns.map((fkCol) => {
451
- const fkValues = [...new Set(table.rows.map((row) => row[fkCol]).filter((v) => v != null))];
451
+ const fkValues = [...new Set(table.rows.map((row) => row[fkCol]).filter(Boolean))];
452
452
  return { column: fkCol, values: fkValues };
453
453
  });
454
454
 
@@ -296,7 +296,7 @@ export class SonamuDictionary {
296
296
 
297
297
  // 파일 재생성
298
298
  const content = this.generateProjectDict(locale, existingEntries, isDefaultLocale);
299
- const formatted = await formatCode(content, "typescript", filePath);
299
+ const formatted = await formatCode(content, filePath);
300
300
  fs.writeFileSync(filePath, formatted, "utf-8");
301
301
  }
302
302
 
@@ -418,7 +418,7 @@ export class SonamuDictionary {
418
418
  const i18nDir = this.ensureI18nDir();
419
419
  const dictPath = path.join(i18nDir, `${locale}.ts`);
420
420
  const content = this.generateProjectDict(locale, entries, isDefaultLocale);
421
- const formatted = await formatCode(content, "typescript", dictPath);
421
+ const formatted = await formatCode(content, dictPath);
422
422
  fs.writeFileSync(dictPath, formatted, "utf-8");
423
423
  }
424
424
 
@@ -631,7 +631,7 @@ export class SonamuDictionary {
631
631
  const stats: Record<string, { total: number; filled: number; percent: number }> = {};
632
632
  const total = rows.length;
633
633
  for (const locale of locales) {
634
- const filled = rows.filter((row) => row[locale] != null && row[locale] !== "").length;
634
+ const filled = rows.filter((row) => !!row[locale]).length;
635
635
  const percent = total > 0 ? Math.round((filled / total) * 100) : 0;
636
636
  stats[locale] = { total, filled, percent };
637
637
  }
@@ -943,7 +943,7 @@ export class Entity {
943
943
  `src/application/${this.names.parentFs}/${this.names.fs}.entity.json`,
944
944
  );
945
945
  const json = this.toJson();
946
- await writeFile(jsonPath, await formatCode(JSON.stringify(json), "json", jsonPath));
946
+ await writeFile(jsonPath, await formatCode(JSON.stringify(json), jsonPath));
947
947
 
948
948
  // reload
949
949
  await EntityManager.register(json);
package/src/index.ts CHANGED
@@ -31,6 +31,12 @@ export * from "./migration/types";
31
31
  export * from "./naite/naite";
32
32
  export * from "./naite/naite-reporter";
33
33
  export * from "./stream/sse";
34
+ export * from "./stream/ws-audience";
35
+ export * from "./stream/ws-cluster-bus";
36
+ export * from "./stream/ws-core";
37
+ export * from "./stream/ws-presence-store";
38
+ export * from "./stream/ws";
39
+ export * from "./stream/ws-registry";
34
40
  export * from "./tasks/decorator";
35
41
  export * from "./template/template";
36
42
  export * from "./template/template-manager";
@@ -774,7 +774,7 @@ async function generateCreateCode_ColumnAndIndexes(
774
774
  table,
775
775
  type: "normal",
776
776
  title: `create__${table}`,
777
- formatted: await formatCode(lines.join("\n"), "typescript", `src/migration/${table}.ts`),
777
+ formatted: await formatCode(lines.join("\n"), `src/migration/${table}.ts`),
778
778
  };
779
779
  }
780
780
 
@@ -871,12 +871,7 @@ function genNormalColumnDefinition(column: MigrationColumn): string {
871
871
  chains.push(`jsonb('${column.name}')`);
872
872
  } else {
873
873
  // type, length
874
- let extraType: string | undefined;
875
- chains.push(
876
- `${column.type}('${column.name}'${
877
- column.length ? `, ${column.length}` : ""
878
- }${extraType ? `, '${extraType}'` : ""})`,
879
- );
874
+ chains.push(`${column.type}('${column.name}'${column.length ? `, ${column.length}` : ""})`);
880
875
  }
881
876
 
882
877
  // nullable
@@ -1099,7 +1094,7 @@ async function generateCreateCode_Foreign(
1099
1094
  table,
1100
1095
  type: "foreign",
1101
1096
  title: `foreign__${table}__${foreignKeysString}`,
1102
- formatted: await formatCode(lines.join("\n"), "typescript", `src/migration/${table}.ts`),
1097
+ formatted: await formatCode(lines.join("\n"), `src/migration/${table}.ts`),
1103
1098
  },
1104
1099
  ];
1105
1100
  }
@@ -1314,7 +1309,7 @@ async function generateAlterCode_ColumnAndIndexes(
1314
1309
  "}",
1315
1310
  ];
1316
1311
 
1317
- const formatted = await formatCode(lines.join("\n"), "typescript", `src/migration/${table}.ts`);
1312
+ const formatted = await formatCode(lines.join("\n"), `src/migration/${table}.ts`);
1318
1313
  const title = [
1319
1314
  "alter",
1320
1315
  table,
@@ -1759,7 +1754,7 @@ async function generateAlterCode_Foreigns(
1759
1754
  "}",
1760
1755
  ];
1761
1756
 
1762
- const formatted = await formatCode(lines.join("\n"), "typescript", `src/migration/${table}.ts`);
1757
+ const formatted = await formatCode(lines.join("\n"), `src/migration/${table}.ts`);
1763
1758
  const title = ["alter", table, "foreigns"].join("_");
1764
1759
 
1765
1760
  return [
@@ -2077,7 +2072,7 @@ async function generatePkTypeChangeMigration(
2077
2072
  "}",
2078
2073
  ];
2079
2074
 
2080
- const formatted = await formatCode(lines.join("\n"), "typescript", `src/migration/${table}.ts`);
2075
+ const formatted = await formatCode(lines.join("\n"), `src/migration/${table}.ts`);
2081
2076
 
2082
2077
  return [
2083
2078
  {
@@ -12,9 +12,10 @@
12
12
  import type { AxiosRequestConfig } from "axios";
13
13
  import axios from "axios";
14
14
  import qs from "qs";
15
- import { useEffect, useRef, useState } from "react";
15
+ import { useCallback, useEffect, useRef, useState } from "react";
16
16
  import { Alert } from "react-native";
17
17
  import { type core, z } from "zod";
18
+ import { type InfiniteData } from "@tanstack/react-query";
18
19
  import { getCurrentLocale } from "@/i18n/sd.generated";
19
20
  import { ExpoEventSource as EventSource } from "@falcondev-oss/expo-event-source-polyfill";
20
21
 
@@ -358,8 +359,24 @@ export type SSEStreamState = {
358
359
  retryCount: number;
359
360
  isEnded: boolean;
360
361
  };
362
+ export type WebSocketChannelOptions = {
363
+ enabled?: boolean;
364
+ retry?: number;
365
+ retryInterval?: number;
366
+ protocols?: string | string[];
367
+ headers?: Record<string, string>;
368
+ };
369
+ export type WebSocketChannelState<TSend extends Record<string, any>> = {
370
+ isConnected: boolean;
371
+ error: string | null;
372
+ retryCount: number;
373
+ readyState: number;
374
+ send<K extends keyof TSend>(event: K, data: TSend[K]): void;
375
+ close(code?: number, reason?: string): void;
376
+ };
377
+ // outbound event 전체를 강제하지 않도록 handler를 optional map으로 둠
361
378
  export type EventHandlers<T> = {
362
- [K in keyof T]: (data: T[K]) => void;
379
+ [K in keyof T]?: (data: T[K]) => void;
363
380
  };
364
381
 
365
382
  export function useSSEStream<T extends Record<string, any>>(
@@ -510,7 +527,7 @@ export function useSSEStream<T extends Record<string, any>>(
510
527
  }
511
528
 
512
529
  try {
513
- const data = JSON.parse(event.data as string);
530
+ const data = JSON.parse(event.data as string, dateReviver);
514
531
  handler(data);
515
532
  } catch (error) {
516
533
  console.error(`Failed to parse SSE data for event ${eventType}:`, error);
@@ -531,7 +548,7 @@ export function useSSEStream<T extends Record<string, any>>(
531
548
  }
532
549
 
533
550
  try {
534
- const data = JSON.parse(event.data as string);
551
+ const data = JSON.parse(event.data as string, dateReviver);
535
552
  // 'message' 핸들러가 있으면 호출
536
553
  const messageHandler = handlersRef.current["message" as keyof T];
537
554
  if (messageHandler) {
@@ -580,7 +597,298 @@ export function useSSEStream<T extends Record<string, any>>(
580
597
  return state;
581
598
  }
582
599
 
600
+ export function useWebSocketChannel<
601
+ TReceive extends Record<string, any>,
602
+ TSend extends Record<string, any>,
603
+ >(
604
+ url: string,
605
+ params: Record<string, any>,
606
+ handlers: EventHandlers<TReceive>,
607
+ options: WebSocketChannelOptions = {},
608
+ ): WebSocketChannelState<TSend> {
609
+ const { enabled = true, retry = 3, retryInterval = 3000, protocols, headers } = options;
610
+
611
+ const [state, setState] = useState<Omit<WebSocketChannelState<TSend>, "send" | "close">>({
612
+ isConnected: false,
613
+ error: null,
614
+ retryCount: 0,
615
+ readyState: 3,
616
+ });
617
+
618
+ const socketRef = useRef<WebSocket | null>(null);
619
+ const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
620
+ const handlersRef = useRef(handlers);
621
+ const manualCloseRef = useRef(false);
622
+ // 최신 연결 식별자를 따로 두어 stale socket 이벤트가 상태를 덮지 못하게 함
623
+ const connectionIdRef = useRef(0);
624
+
625
+ useEffect(() => {
626
+ handlersRef.current = handlers;
627
+ }, [handlers]);
628
+
629
+ const close = (code?: number, reason?: string) => {
630
+ manualCloseRef.current = true;
631
+ if (retryTimeoutRef.current) {
632
+ clearTimeout(retryTimeoutRef.current);
633
+ retryTimeoutRef.current = null;
634
+ }
635
+ if (socketRef.current) {
636
+ socketRef.current.close(code, reason);
637
+ }
638
+ };
639
+
640
+ const send = <K extends keyof TSend>(event: K, data: TSend[K]) => {
641
+ const socket = socketRef.current;
642
+ if (!socket || socket.readyState !== 1) {
643
+ setState((prev) => ({
644
+ ...prev,
645
+ error: "WebSocket is not connected",
646
+ }));
647
+ return;
648
+ }
649
+
650
+ socket.send(
651
+ JSON.stringify({
652
+ event,
653
+ data,
654
+ }),
655
+ );
656
+ };
657
+
658
+ const connect = () => {
659
+ if (!enabled) {
660
+ return;
661
+ }
662
+
663
+ const connectionId = ++connectionIdRef.current;
664
+ manualCloseRef.current = false;
665
+
666
+ if (socketRef.current) {
667
+ socketRef.current.close();
668
+ socketRef.current = null;
669
+ }
670
+
671
+ if (retryTimeoutRef.current) {
672
+ clearTimeout(retryTimeoutRef.current);
673
+ retryTimeoutRef.current = null;
674
+ }
675
+
676
+ const queryString = qs.stringify(params);
677
+ // 앱 shared는 Node/Native 실행도 염두에 두므로 baseURL과 추가 headers를 함께 반영함
678
+ const baseUrl = axios.defaults.baseURL ?? "$[[baseUrl]]";
679
+ const wsBaseUrl = baseUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
680
+ const fullUrl = new URL(queryString ? `${url}?${queryString}` : url, wsBaseUrl).toString();
681
+ const socket = new WebSocket(fullUrl, protocols, {
682
+ headers: {
683
+ "Accept-Language": getCurrentLocale(),
684
+ ...(headers ?? {}),
685
+ },
686
+ });
687
+ socketRef.current = socket;
688
+
689
+ setState((prev) => ({
690
+ ...prev,
691
+ isConnected: false,
692
+ error: null,
693
+ readyState: socket.readyState,
694
+ }));
695
+
696
+ // socketRef.current !== socket 가드는 이전 연결의 늦은 콜백을 무시하기 위한 장치임
697
+ socket.addEventListener("open", () => {
698
+ if (socketRef.current !== socket) {
699
+ return;
700
+ }
701
+
702
+ setState((prev) => ({
703
+ ...prev,
704
+ isConnected: true,
705
+ error: null,
706
+ retryCount: 0,
707
+ readyState: socket.readyState,
708
+ }));
709
+ });
710
+
711
+ socket.addEventListener("message", (event) => {
712
+ if (socketRef.current !== socket) {
713
+ return;
714
+ }
715
+
716
+ try {
717
+ const payload = JSON.parse(event.data as string, dateReviver) as {
718
+ event: keyof TReceive;
719
+ data: TReceive[keyof TReceive];
720
+ };
721
+ const handler = handlersRef.current[payload.event];
722
+ if (handler) {
723
+ handler(payload.data);
724
+ }
725
+ } catch (error) {
726
+ console.error("Failed to parse WebSocket message:", error);
727
+ }
728
+ });
729
+
730
+ socket.addEventListener("error", () => {
731
+ if (socketRef.current !== socket) {
732
+ return;
733
+ }
734
+
735
+ setState((prev) => ({
736
+ ...prev,
737
+ isConnected: false,
738
+ error: "WebSocket connection failed",
739
+ readyState: socket.readyState,
740
+ }));
741
+ });
742
+
743
+ socket.addEventListener("close", (event) => {
744
+ if (socketRef.current !== socket) {
745
+ return;
746
+ }
747
+
748
+ socketRef.current = null;
749
+
750
+ setState((prev) => ({
751
+ ...prev,
752
+ isConnected: false,
753
+ readyState: socket.readyState,
754
+ }));
755
+
756
+ if (manualCloseRef.current || connectionIdRef.current !== connectionId) {
757
+ return;
758
+ }
759
+
760
+ // 정책 위반/과대 payload close는 재시도보다 명시적 에러 노출이 우선임
761
+ if (!isRetryableWebSocketCloseCode(event.code)) {
762
+ setState((prev) => ({
763
+ ...prev,
764
+ error:
765
+ event.code === 1008 || event.code === 1009
766
+ ? `WebSocket rejected by server (code: ${event.code})`
767
+ : `WebSocket closed (code: ${event.code})`,
768
+ }));
769
+ return;
770
+ }
771
+
772
+ setState((prev) => {
773
+ if (prev.retryCount >= retry) {
774
+ return {
775
+ ...prev,
776
+ error: `Connection failed after ${retry} attempts`,
777
+ };
778
+ }
779
+
780
+ retryTimeoutRef.current = setTimeout(() => {
781
+ if (connectionIdRef.current !== connectionId) {
782
+ return;
783
+ }
784
+
785
+ setState((inner) => ({
786
+ ...inner,
787
+ retryCount: inner.retryCount + 1,
788
+ }));
789
+ connect();
790
+ }, retryInterval);
791
+
792
+ return prev;
793
+ });
794
+ });
795
+ };
796
+
797
+ useEffect(() => {
798
+ if (enabled) {
799
+ setState({
800
+ isConnected: false,
801
+ error: null,
802
+ retryCount: 0,
803
+ readyState: 3,
804
+ });
805
+ connect();
806
+ }
807
+
808
+ return () => {
809
+ connectionIdRef.current += 1;
810
+ manualCloseRef.current = true;
811
+ if (socketRef.current) {
812
+ socketRef.current.close();
813
+ socketRef.current = null;
814
+ }
815
+ if (retryTimeoutRef.current) {
816
+ clearTimeout(retryTimeoutRef.current);
817
+ retryTimeoutRef.current = null;
818
+ }
819
+ };
820
+ }, [url, JSON.stringify(params), enabled, JSON.stringify(protocols), JSON.stringify(headers)]);
821
+
822
+ return {
823
+ ...state,
824
+ send,
825
+ close,
826
+ };
827
+ }
828
+
829
+ function isRetryableWebSocketCloseCode(code: number): boolean {
830
+ if (code === 1000) {
831
+ return false;
832
+ }
833
+
834
+ return ![1002, 1003, 1007, 1008, 1009].includes(code);
835
+ }
836
+
583
837
  /*
584
838
  Dictionary Helper
585
839
  */
586
840
  $[[dictUtils]]
841
+ /*
842
+ Query helpers
843
+ */
844
+ type InfinitePage<TRow> = { rows: TRow[]; total: number };
845
+ type DedupedInfiniteData<TRow> = InfiniteData<InfinitePage<TRow>> & {
846
+ rows: TRow[];
847
+ total: number;
848
+ };
849
+
850
+ // useInfiniteQuery의 select에 꽂아 pages/pageParams 원본은 유지하면서
851
+ // 평탄화된 rows와 첫 페이지의 total을 data에 함께 노출합니다.
852
+ // 각 row가 id를 갖는 경우 id 기준으로 중복 제거합니다. id가 없으면 그대로 유지합니다.
853
+ export function dedupeAndFlatten<TRow extends { id?: unknown }>(
854
+ data: InfiniteData<InfinitePage<TRow>>,
855
+ ): DedupedInfiniteData<TRow> {
856
+ const seen = new Set<unknown>();
857
+ const rows: TRow[] = [];
858
+ for (const page of data.pages) {
859
+ for (const row of page?.rows ?? []) {
860
+ const id = row?.id;
861
+ if (id !== null) {
862
+ if (seen.has(id)) {
863
+ continue;
864
+ }
865
+ seen.add(id);
866
+ }
867
+ rows.push(row);
868
+ }
869
+ }
870
+ const total = data.pages[0]?.total ?? 0;
871
+ return {
872
+ pages: data.pages,
873
+ pageParams: data.pageParams,
874
+ rows,
875
+ total,
876
+ };
877
+ }
878
+
879
+ // TanStack Query 결과에 수동 refresh 진입점과 새로고침 중 상태를 덧붙여 줍니다.
880
+ // isRefreshing은 query.isFetching과 독립적으로 이 함수 호출로 발생한 새로고침에 한정됩니다.
881
+ export function useRefreshable<T extends { refetch: () => Promise<unknown> }>(
882
+ query: T,
883
+ ): T & { refresh: () => Promise<void>; isRefreshing: boolean } {
884
+ const [isRefreshing, setIsRefreshing] = useState(false);
885
+ const refresh = useCallback(async () => {
886
+ setIsRefreshing(true);
887
+ try {
888
+ await query.refetch();
889
+ } finally {
890
+ setIsRefreshing(false);
891
+ }
892
+ }, [query]);
893
+ return { ...query, refresh, isRefreshing };
894
+ }