create-sonamu 0.1.4 → 0.1.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.
package/README.md CHANGED
@@ -114,19 +114,17 @@ pnpm create sonamu my_app \
114
114
  cd my_app/packages/api
115
115
  pnpm docker:up
116
116
 
117
- # 2. API 서버 시작 (Sonamu UI 포함)
118
- pnpm dev
119
-
120
- # 3. Web 서버 시작 (새 터미널)
121
- cd my_app/packages/web
117
+ # 2. 개발 서버 시작 (API + Web 통합 모드)
122
118
  pnpm dev
123
119
  ```
124
120
 
125
121
  🎉 **완료!**
126
122
 
127
- - API: http://localhost:34900
123
+ - API + Web: http://localhost:34900 (통합 모드)
128
124
  - Sonamu UI: http://localhost:34900/sonamu-ui (엔티티 관리)
129
- - Web: http://localhost:3028
125
+
126
+ > **참고**: `pnpm dev`는 `sonamu dev`를 실행하며, 기본적으로 API와 Web을 하나의 포트로 통합 서빙합니다.
127
+ > Web만 별도로 실행하려면 `sonamu dev web`을 사용하세요.
130
128
 
131
129
  ---
132
130
 
@@ -167,14 +165,14 @@ pnpm dev
167
165
 
168
166
  | 서비스 | 포트 | URL |
169
167
  | -------------- | ------------------ | -------------------------------- |
170
- | **API 서버** | `BASE_PORT` (34900) | http://localhost:34900 |
168
+ | **API + Web (통합)** | `BASE_PORT` (34900) | http://localhost:34900 |
171
169
  | **Sonamu UI** | - | http://localhost:34900/sonamu-ui |
172
- | **Web 개발** | `BASE_PORT + 2000` (3028) | http://localhost:3028 |
173
170
  | **PostgreSQL** | 5432 | - |
174
171
 
175
172
  **참고**:
173
+ - `sonamu dev` (= `sonamu dev all`)은 API와 Web을 하나의 포트(one-port)로 통합 서빙합니다
176
174
  - Sonamu UI는 API 서버에 통합되어 있어 별도 실행이 필요 없습니다
177
- - Web 개발 중에는 Vite dev 서버(3028)로, 프로덕션에서는 빌드 후 API 서버에서 서빙됩니다
175
+ - Web 별도로 실행하려면 `sonamu dev web`을 사용하세요
178
176
 
179
177
  ---
180
178
 
@@ -184,8 +182,10 @@ pnpm dev
184
182
 
185
183
  | 명령어 | 설명 |
186
184
  | ---------------- | ----------------------------------------- |
187
- | `pnpm dev` | 개발 서버 시작 (HMR, Sonamu UI 포함) |
188
- | `pnpm build` | 프로덕션 빌드 |
185
+ | `pnpm dev` | 통합 개발 서버 시작 (= `sonamu dev all`) |
186
+ | `pnpm build` | 전체 프로덕션 빌드 (= `sonamu build all`) |
187
+ | `pnpm build api` | API만 빌드 (= `sonamu build api`) |
188
+ | `pnpm build web` | Web만 빌드 (= `sonamu build web`) |
189
189
  | `pnpm start` | 프로덕션 서버 실행 |
190
190
  | `pnpm test` | 테스트 실행 |
191
191
  | `pnpm docker:up` | Docker 데이터베이스 시작 |
@@ -196,15 +196,26 @@ pnpm dev
196
196
  | `pnpm sonamu skills sync` | 공식 Skills 동기화 |
197
197
  | `pnpm sonamu skills create <name>` | 커스텀 Skill 생성 |
198
198
 
199
- ### Web (`web/`)
199
+ ### 개발 서버 모드
200
+
201
+ | 명령어 | 설명 |
202
+ | ------------------------------- | ---------------------------------------- |
203
+ | `sonamu dev` | 통합 모드 (= `sonamu dev all`) |
204
+ | `sonamu dev all` | 통합 모드 (one-port: API + Web) |
205
+ | `sonamu dev api` | API-only 모드 (Vite 통합 비활성) |
206
+ | `sonamu dev web` | Vite 단독 실행 |
207
+ | `sonamu dev web -- --port 3028` | Vite 옵션 전달 |
208
+
209
+ ### 빌드
200
210
 
201
- | 명령어 | 설명 |
202
- | -------------- | ----------------------------- |
203
- | `pnpm dev` | 개발 서버 시작 (Vite) |
204
- | `pnpm build` | 프로덕션 빌드 (Client + SSR) |
205
- | `pnpm preview` | 빌드 결과 미리보기 |
211
+ | 명령어 | 설명 |
212
+ | ------------------ | ----------------------------------------- |
213
+ | `sonamu build` | 전체 빌드 (= `sonamu build all`) |
214
+ | `sonamu build all` | 전체 빌드 (API + Web) |
215
+ | `sonamu build api` | API만 빌드 |
216
+ | `sonamu build web` | Web만 빌드 |
206
217
 
207
- **참고**: `pnpm build`는 클라이언트와 SSR 서버를 모두 빌드합니다. 빌드 결과는 `api/public/web`과 `api/dist/ssr`에 복사됩니다.
218
+ **참고**: `sonamu build web`은 클라이언트와 SSR 서버를 모두 빌드합니다. 빌드 결과는 `web/dist/`에 생성되고, `api/web-dist/`로 복사됩니다.
208
219
 
209
220
  ---
210
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sonamu",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Create a new Sonamu project",
5
5
  "keywords": [
6
6
  "sonamu",
@@ -48,17 +48,19 @@ cd packages/api
48
48
  pnpm docker:up
49
49
  ```
50
50
 
51
- ### 3. API 서버 시작
51
+ ### 3. 개발 서버 시작
52
52
 
53
53
  ```bash
54
54
  cd packages/api
55
55
  pnpm dev
56
56
  ```
57
57
 
58
- API 서버가 시작되면 다음 주소로 접속할 수 있습니다:
59
- - **API 서버**: http://localhost:34900
58
+ 개발 서버가 시작되면 다음 주소로 접속할 수 있습니다:
59
+ - **API + Web (통합)**: http://localhost:34900
60
60
  - **Sonamu UI**: http://localhost:34900/sonamu-ui (엔티티 관리)
61
61
 
62
+ > `pnpm dev`는 `sonamu dev`를 실행하며, 기본적으로 API와 Web을 하나의 포트로 통합 서빙합니다 (`sonamu dev all`과 동일).
63
+
62
64
  ### 4. 첫 번째 엔티티 생성
63
65
 
64
66
  1. Sonamu UI 열기: http://localhost:34900/sonamu-ui
@@ -66,14 +68,11 @@ API 서버가 시작되면 다음 주소로 접속할 수 있습니다:
66
68
  3. 엔티티 정의 (예: `User`, `Post`)
67
69
  4. `api/src/application/`과 `web/src/services/`에 파일이 자동으로 생성됩니다
68
70
 
69
- ### 5. Web 서버 시작 (새 터미널에서)
71
+ ### 5. 확인
70
72
 
71
- ```bash
72
- cd packages/web
73
- pnpm dev
74
- ```
73
+ http://localhost:34900 을 열어서 앱을 확인하세요!
75
74
 
76
- http://localhost:3028 열어서 앱을 확인하세요!
75
+ > Web만 별도로 실행하고 싶다면 `sonamu dev web`을 사용할 수 있습니다 (`--` 뒤에 Vite 옵션 전달 가능).
77
76
 
78
77
  ---
79
78
 
@@ -105,12 +104,11 @@ web/src/services/
105
104
 
106
105
  ## 🌐 포트 구성
107
106
 
108
- | 서비스 | 포트 | URL |
109
- | ----------- | ----------------------- | ------------------------------- |
110
- | API 서버 | `BASE_PORT` (기본 34900) | http://localhost:34900 |
111
- | Sonamu UI | - | http://localhost:34900/sonamu-ui |
112
- | Web 클라이언트 | `BASE_PORT + 2000` | http://localhost:3028 |
113
- | PostgreSQL | 5432 | - |
107
+ | 서비스 | 포트 | URL |
108
+ | ------------------ | ------------------------ | ------------------------------- |
109
+ | API + Web (통합) | `BASE_PORT` (기본 34900) | http://localhost:34900 |
110
+ | Sonamu UI | - | http://localhost:34900/sonamu-ui |
111
+ | PostgreSQL | 5432 | - |
114
112
 
115
113
  ## 📜 주요 스크립트
116
114
 
@@ -124,27 +122,30 @@ web/src/services/
124
122
 
125
123
  ### API (`packages/api/`)
126
124
 
127
- | 명령어 | 설명 |
128
- | ------------------- | -------------------------------------- |
129
- | `pnpm dev` | 개발 서버 시작 (HMR, Sonamu UI 포함) |
130
- | `pnpm build` | 프로덕션 빌드 |
131
- | `pnpm start` | 프로덕션 서버 시작 |
132
- | `pnpm test` | 테스트 실행 |
133
- | `pnpm docker:up` | Docker DB 시작 |
134
- | `pnpm docker:down` | Docker DB 중지 |
135
- | `pnpm docker:reset` | Docker DB 초기화 (볼륨 삭제 후 재시작) |
136
- | `pnpm dump` | 테스트 DB 덤프 생성 |
137
- | `pnpm seed` | 덤프를 fixture DB 적용 |
125
+ | 명령어 | 설명 |
126
+ | ------------------- | ------------------------------------------ |
127
+ | `pnpm dev` | 통합 개발 서버 시작 (= `sonamu dev all`) |
128
+ | `pnpm build` | 전체 프로덕션 빌드 (= `sonamu build all`) |
129
+ | `pnpm build api` | API만 빌드 (= `sonamu build api`) |
130
+ | `pnpm build web` | Web만 빌드 (= `sonamu build web`) |
131
+ | `pnpm start` | 프로덕션 서버 시작 |
132
+ | `pnpm test` | 테스트 실행 |
133
+ | `pnpm docker:up` | Docker DB 시작 |
134
+ | `pnpm docker:down` | Docker DB 중지 |
135
+ | `pnpm docker:reset` | Docker DB 초기화 (볼륨 삭제 후 재시작) |
136
+ | `pnpm dump` | 테스트 DB 덤프 생성 |
137
+ | `pnpm seed` | 덤프를 fixture DB에 적용 |
138
138
  | `pnpm sonamu skills sync` | 공식 Skills 동기화 |
139
139
  | `pnpm sonamu skills create <name>` | 커스텀 Skill 생성 |
140
140
 
141
- ### Web (`packages/web/`)
141
+ ### 개발 서버 모드
142
142
 
143
- | 명령어 | 설명 |
144
- | -------------- | ------------------ |
145
- | `pnpm dev` | 개발 서버 시작 |
146
- | `pnpm build` | 프로덕션 빌드 |
147
- | `pnpm preview` | 빌드 결과 미리보기 |
143
+ | 명령어 | 설명 |
144
+ | -------------------------------------- | ------------------------------ |
145
+ | `sonamu dev` / `sonamu dev all` | 통합 모드 (one-port: API + Web) |
146
+ | `sonamu dev api` | API-only 모드 |
147
+ | `sonamu dev web` | Vite 단독 실행 |
148
+ | `sonamu dev web -- --port 3028 --host 0.0.0.0` | Vite 옵션 전달 |
148
149
 
149
150
  ## 🛠️ 개발 워크플로우
150
151
 
@@ -3,7 +3,7 @@ node_modules/
3
3
 
4
4
  # Production builds
5
5
  dist/
6
- dist-ssr/
6
+ web-dist/
7
7
 
8
8
  # Environment variables
9
9
  .env
@@ -39,7 +39,7 @@
39
39
  "knex": "^3.1.0",
40
40
  "pg": "^8.16.3",
41
41
  "radashi": "^12.2.0",
42
- "sonamu": "^0.7.51",
42
+ "sonamu": "^0.8.0",
43
43
  "zod": "^4.3.6"
44
44
  },
45
45
  "devDependencies": {
@@ -49,8 +49,8 @@ export default defineConfig({
49
49
  // nothing yet
50
50
  },
51
51
  },
52
-
53
- auth:{
52
+
53
+ auth: {
54
54
  emailAndPassword: { enabled: true },
55
55
  baseURL: process.env.BETTER_AUTH_URL ?? `http://${host}:${port}`,
56
56
  secret: process.env.BETTER_AUTH_SECRET ?? "miomock-secret-key-change-this-in-production",
@@ -1,44 +1,44 @@
1
1
  import type { MakeDirectoryOptions, Mode, PathLike, RmOptions } from "fs";
2
2
  import type { FileHandle } from "fs/promises";
3
- import { Naite } from "sonamu";
3
+ // import { Naite } from "sonamu";
4
4
  import { vi } from "vitest";
5
5
 
6
- // GlobalMock: fs/promises
6
+ // GlobalMock: fs/promises (사용 예시 - 필요시 활성화)
7
7
  vi.mock("fs/promises", async (importOriginal) => {
8
8
  const actual = (await importOriginal()) as typeof import("fs/promises");
9
9
  return {
10
10
  ...actual,
11
11
  access: vi.fn((path: PathLike, mode?: number) => {
12
- const vfs = Naite.get("mock:fs/promises:virtualFileSystem").result();
13
- if (vfs.some((v) => v === path)) {
14
- return Promise.resolve();
15
- }
12
+ // const vfs = Naite.get("mock:fs/promises:virtualFileSystem").result();
13
+ // if (vfs.some((v) => v === path)) {
14
+ // return Promise.resolve();
15
+ // }
16
16
 
17
17
  return actual.access(path, mode);
18
18
  }),
19
- mkdir: vi.fn(
20
- async (
21
- path: PathLike,
22
- options?: MakeDirectoryOptions | Mode | null,
23
- ): Promise<string | undefined> => {
24
- Naite.t("fs:mkdir", { path, options });
25
- if (typeof options === "object" && options?.recursive) {
26
- return typeof path === "string" ? path : path.toString();
27
- }
28
- return undefined;
29
- },
30
- ),
31
- writeFile: vi.fn((path: PathLike | FileHandle, data: string | Buffer | Uint8Array) => {
32
- const filePath = typeof path === "string" ? path : path.toString();
19
+ // mkdir: vi.fn(
20
+ // async (
21
+ // path: PathLike,
22
+ // options?: MakeDirectoryOptions | Mode | null,
23
+ // ): Promise<string | undefined> => {
24
+ // // Naite.t("fs:mkdir", { path, options });
25
+ // if (typeof options === "object" && options?.recursive) {
26
+ // return typeof path === "string" ? path : path.toString();
27
+ // }
28
+ // return undefined;
29
+ // },
30
+ // ),
31
+ // writeFile: vi.fn((path: PathLike | FileHandle, data: string | Buffer | Uint8Array) => {
32
+ // const filePath = typeof path === "string" ? path : path.toString();
33
33
 
34
- Naite.t(`fs/promises:writeFile`, { path: filePath, data });
35
- }),
36
- rm: vi.fn(async (path: PathLike, options?: RmOptions) => {
37
- const filePath = typeof path === "string" ? path : path.toString();
34
+ // // Naite.t(`fs/promises:writeFile`, { path: filePath, data });
35
+ // }),
36
+ // rm: vi.fn(async (path: PathLike, options?: RmOptions) => {
37
+ // const filePath = typeof path === "string" ? path : path.toString();
38
38
 
39
- Naite.t(`fs/promises:rm`, { path: filePath, options });
40
- // 실제 삭제하지 않고 기록만 함
41
- return Promise.resolve();
42
- }),
39
+ // // Naite.t(`fs/promises:rm`, { path: filePath, options });
40
+ // // 실제 삭제하지 않고 기록만 함
41
+ // return Promise.resolve();
42
+ // }),
43
43
  };
44
44
  });
@@ -10,7 +10,7 @@
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
- "@sonamu-kit/react-components": "^0.1.8",
13
+ "@sonamu-kit/react-components": "^0.2.2",
14
14
  "@tanstack/react-query": "^5.90.12",
15
15
  "@tanstack/react-router": "1.143.11",
16
16
  "axios": "^1.13.2",
@@ -3,7 +3,7 @@ import { useRouterState } from "@tanstack/react-router";
3
3
  import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
4
4
  import { type ReactNode, Suspense, useEffect } from "react";
5
5
  import Sidebar from "./components/Sidebar";
6
- import { setLocale } from "./i18n/sd.generated";
6
+ import { SUPPORTED_LOCALES, setLocale } from "./i18n/sd.generated";
7
7
 
8
8
  interface AppProps {
9
9
  children?: ReactNode;
@@ -17,8 +17,8 @@ function App({ children }: AppProps) {
17
17
  useEffect(() => {
18
18
  // 브라우저 locale 감지
19
19
  const browserLocale = navigator.language.split("-")[0];
20
- if (["ko", "en"].includes(browserLocale)) {
21
- setLocale(browserLocale as "ko" | "en");
20
+ if (SUPPORTED_LOCALES.includes(browserLocale as typeof SUPPORTED_LOCALES[number])) {
21
+ setLocale(browserLocale as typeof SUPPORTED_LOCALES[number]);
22
22
  }
23
23
  }, []);
24
24
 
@@ -2,7 +2,7 @@
2
2
  // 초기 빈 상태 - sonamu dev 실행 시 실제 내용으로 대체됩니다.
3
3
 
4
4
  const DEFAULT_LOCALE = "ko" as const;
5
- const SUPPORTED_LOCALES = ["ko", "en"] as const;
5
+ export const SUPPORTED_LOCALES = ["ko", "en"] as const;
6
6
  let _currentLocale: (typeof SUPPORTED_LOCALES)[number] = DEFAULT_LOCALE;
7
7
 
8
8
  export function setLocale(locale: (typeof SUPPORTED_LOCALES)[number]) {
@@ -0,0 +1,3 @@
1
+ import { createAuthClient } from "better-auth/react";
2
+
3
+ export const { signIn, signUp, useSession, signOut } = createAuthClient();
@@ -1,13 +1,27 @@
1
- // 자동 생성 파일 - sonamu sync로 갱신됨
2
- // 초기 빈 상태 - sonamu dev 실행 시 실제 내용으로 대체됩니다.
3
-
4
1
  /**
5
- * ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver
2
+ * @generated
3
+ * 최초 1회 생성되며, 이후에는 덮어쓰지 않습니다.
4
+ * 필요시 직접 수정할 수 있습니다.
6
5
  */
6
+ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: shared */
7
+ /** biome-ignore-all lint/suspicious/noExplicitAny: shared */
8
+
9
+ /*
10
+ fetch
11
+ */
12
+ import type { AxiosRequestConfig } from "axios";
13
+ import axios from "axios";
14
+ import qs from "qs";
15
+ import { type core, z } from "zod";
16
+ import { EventSource } from "eventsource";
17
+ import { getCurrentLocale } from "../i18n/sd.generated";
18
+
19
+ // ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver
7
20
  export function dateReviver(_key: string, value: any): any {
8
21
  if (typeof value === "string") {
9
22
  // ISO 8601 형식: 2024-01-15T09:30:00.000Z 또는 2024-01-15T09:30:00+09:00
10
23
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?$/;
24
+
11
25
  // Timezone 포맷: 2024-01-15 09:30:00+09:00
12
26
  const timezoneRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
13
27
 
@@ -20,3 +34,546 @@ export function dateReviver(_key: string, value: any): any {
20
34
  }
21
35
  return value;
22
36
  }
37
+
38
+ axios.defaults.transformResponse = [
39
+ (data) => {
40
+ if (typeof data === "string") {
41
+ try {
42
+ return JSON.parse(data, dateReviver);
43
+ } catch {
44
+ return data;
45
+ }
46
+ }
47
+ return data;
48
+ },
49
+ ];
50
+
51
+ export async function fetch(options: AxiosRequestConfig) {
52
+ try {
53
+ const res = await axios({
54
+ ...options,
55
+ });
56
+ return res.data;
57
+ } catch (e: unknown) {
58
+ if (axios.isAxiosError(e) && e.response && e.response.data) {
59
+ const d = e.response.data as {
60
+ message: string;
61
+ issues: core.$ZodIssue[];
62
+ };
63
+ throw new SonamuError(e.response.status, d.message, d.issues);
64
+ }
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ export function toFormData(
70
+ obj: Record<string, unknown>,
71
+ formData = new FormData(),
72
+ prefix = "",
73
+ ): FormData {
74
+ for (const [key, value] of Object.entries(obj)) {
75
+ const formKey = prefix ? `${prefix}[${key}]` : key;
76
+
77
+ if (value instanceof File || value instanceof Blob) {
78
+ formData.append(formKey, value);
79
+ } else if (Array.isArray(value)) {
80
+ value.forEach((item, index) => {
81
+ toFormData({ [index]: item }, formData, formKey);
82
+ });
83
+ } else if (value !== null && value !== undefined && typeof value === "object") {
84
+ toFormData(value as Record<string, unknown>, formData, formKey); // 재귀로 펼치기
85
+ } else if (value !== null && value !== undefined) {
86
+ formData.append(formKey, String(value));
87
+ }
88
+ }
89
+
90
+ return formData;
91
+ }
92
+
93
+ export class SonamuError extends Error {
94
+ isSonamuError: boolean;
95
+
96
+ constructor(
97
+ public code: number,
98
+ public message: string,
99
+ public issues: z.ZodIssue[],
100
+ ) {
101
+ super(message);
102
+ this.isSonamuError = true;
103
+ }
104
+ }
105
+ export function isSonamuError(e: any): e is SonamuError {
106
+ return e && e.isSonamuError === true;
107
+ }
108
+
109
+ export function defaultCatch(e: any) {
110
+ if (isSonamuError(e)) {
111
+ alert(e.message);
112
+ } else {
113
+ alert("에러 발생");
114
+ }
115
+ }
116
+
117
+ /*
118
+ Isomorphic Types
119
+ */
120
+ // semanticQuery가 있으면 similarity를 추가하는 조건부 타입
121
+ type WithSimilarity<LP, T> = LP extends { semanticQuery: Record<string, unknown> }
122
+ ? T & { similarity: number }
123
+ : T;
124
+
125
+ export type ListResult<
126
+ LP extends { queryMode?: SonamuQueryMode },
127
+ T,
128
+ > = LP["queryMode"] extends "list"
129
+ ? { rows: WithSimilarity<LP, T>[] }
130
+ : LP["queryMode"] extends "count"
131
+ ? { total: number }
132
+ : { rows: WithSimilarity<LP, T>[]; total: number };
133
+
134
+ export const SonamuQueryMode = z.enum(["both", "list", "count"]);
135
+ export type SonamuQueryMode = z.infer<typeof SonamuQueryMode>;
136
+
137
+ /* Filter Types */
138
+ // Prop 타입별 허용 연산자
139
+ export const operatorsByPropType = {
140
+ string: ["eq", "ne", "contains", "startsWith", "endsWith", "in", "notIn", "isNull", "isNotNull"],
141
+ integer: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "between", "isNull", "isNotNull"],
142
+ numeric: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "between", "isNull", "isNotNull"],
143
+ boolean: ["eq", "ne", "isNull", "isNotNull"],
144
+ date: ["eq", "ne", "before", "after", "between", "isNull", "isNotNull"],
145
+ datetime: ["eq", "ne", "before", "after", "between", "isNull", "isNotNull"],
146
+ enum: ["eq", "ne", "in", "notIn", "isNull", "isNotNull"],
147
+ json: ["isNull", "isNotNull"],
148
+ } as const;
149
+
150
+ // Prop 타입별 기본 연산자
151
+ export const defaultOperatorByPropType = {
152
+ string: "contains",
153
+ integer: "eq",
154
+ numeric: "eq",
155
+ boolean: "eq",
156
+ date: "eq",
157
+ datetime: "eq",
158
+ enum: "eq",
159
+ json: "isNull",
160
+ } as const;
161
+
162
+ // operatorsByPropType에서 파생되는 타입들
163
+ export type FilterPropType = keyof typeof operatorsByPropType;
164
+ export type FilterOperator = (typeof operatorsByPropType)[keyof typeof operatorsByPropType][number];
165
+
166
+ // 특정 prop 타입에 허용되는 연산자 유니온
167
+ type OperatorForPropType<TPropType extends FilterPropType> =
168
+ (typeof operatorsByPropType)[TPropType][number];
169
+
170
+ // 연산자별 기대 값 타입
171
+ type OperatorValue<T, K extends FilterOperator> = K extends "in" | "notIn"
172
+ ? T[]
173
+ : K extends "between"
174
+ ? [T, T]
175
+ : K extends "isNull" | "isNotNull"
176
+ ? boolean
177
+ : T;
178
+
179
+ // 특정 연산자 집합에 대한 필터 조건 타입
180
+ type ConditionForOperators<T, TOps extends FilterOperator> =
181
+ | T
182
+ | { [K in TOps]?: OperatorValue<T, K> };
183
+
184
+ /**
185
+ * 필터 조건 - 타입에 따라 사용 가능한 연산자가 제한
186
+ */
187
+ export type FilterCondition<T> =
188
+ NonNullable<T> extends number
189
+ ? ConditionForOperators<NonNullable<T>, OperatorForPropType<"integer">>
190
+ : NonNullable<T> extends string
191
+ ? ConditionForOperators<NonNullable<T>, OperatorForPropType<"string">>
192
+ : NonNullable<T> extends Date
193
+ ? ConditionForOperators<NonNullable<T>, OperatorForPropType<"date">>
194
+ : NonNullable<T> extends boolean
195
+ ? ConditionForOperators<NonNullable<T>, OperatorForPropType<"boolean">>
196
+ : // Fallback: 비원시 타입은 null 체크만 허용
197
+ ConditionForOperators<NonNullable<T>, OperatorForPropType<"json">>;
198
+
199
+ /**
200
+ * 필터 쿼리
201
+ * 엔티티의 각 필드에 대한 필터 조건 정의
202
+ */
203
+ export type FilterQuery<TEntity, TNumericKeys extends keyof TEntity = never> = {
204
+ [K in keyof TEntity]?: K extends TNumericKeys
205
+ ? ConditionForOperators<NonNullable<TEntity[K]>, OperatorForPropType<"numeric">>
206
+ : FilterCondition<TEntity[K]>;
207
+ };
208
+
209
+ /**
210
+ * Sonamu 필터 적용 타입
211
+ * Entity에서 제외할 필드와 numeric 필드를 받아서 최종 FilterQuery 타입을 생성
212
+ */
213
+ export type ApplySonamuFilter<
214
+ TEntity,
215
+ TOmitKeys extends keyof TEntity = never,
216
+ TNumericKeys extends Exclude<keyof TEntity, TOmitKeys> = never,
217
+ > = FilterQuery<Omit<TEntity, TOmitKeys>, TNumericKeys>;
218
+
219
+
220
+ /**
221
+ * 필드명과 값을 기반으로 FilterPropType을 추론
222
+ */
223
+ export function getFieldPropType(
224
+ fieldName: string,
225
+ value: any,
226
+ numericColumns: readonly string[]
227
+ ): FilterPropType {
228
+ // numeric 타입 체크 (명시적으로 지정된 컬럼)
229
+ if (numericColumns.includes(fieldName)) {
230
+ return "numeric";
231
+ }
232
+
233
+ // 값 기반 타입 추론
234
+ if (value instanceof Date) {
235
+ // Date 객체의 시간 정보 확인
236
+ const hasTime = value.getHours() !== 0 || value.getMinutes() !== 0 || value.getSeconds() !== 0;
237
+ return hasTime ? "datetime" : "date";
238
+ }
239
+
240
+ if (typeof value === "number") {
241
+ return "integer";
242
+ }
243
+
244
+ if (typeof value === "boolean") {
245
+ return "boolean";
246
+ }
247
+
248
+ // JSON 타입 (객체/배열)
249
+ if (value !== null && typeof value === "object") {
250
+ return "json";
251
+ }
252
+
253
+ // 기본값: string
254
+ return "string";
255
+ }
256
+
257
+ /* Semantic Query */
258
+ export const SonamuSemanticParams = z.object({
259
+ semanticQuery: z.object({
260
+ embedding: z.array(z.number()).min(1024).max(1024),
261
+ threshold: z.number().optional(),
262
+ method: z.enum(["cosine", "l2", "inner_product"]).optional(),
263
+ }),
264
+ });
265
+ export type SonamuSemanticParams = z.infer<typeof SonamuSemanticParams>;
266
+
267
+ /*
268
+ Utils
269
+ */
270
+ export function zArrayable<T extends z.ZodTypeAny>(
271
+ shape: T,
272
+ ): z.ZodUnion<readonly [T, z.ZodArray<T>]> {
273
+ return z.union([shape, shape.array()]);
274
+ }
275
+
276
+ /*
277
+ Custom Scalars
278
+ */
279
+ export const SQLDateTimeString = z
280
+ .string()
281
+ .regex(/([0-9]{4}-[0-9]{2}-[0-9]{2}( [0-9]{2}:[0-9]{2}:[0-9]{2})*)$/, {
282
+ message: "잘못된 SQLDate 타입",
283
+ })
284
+ .min(10)
285
+ .max(19)
286
+ .describe("SQLDateTimeString");
287
+ export type SQLDateTimeString = z.infer<typeof SQLDateTimeString>;
288
+
289
+ /**
290
+ * SonamuFile Types
291
+ */
292
+ export interface SonamuFile {
293
+ name: string;
294
+ url: string;
295
+ mime_type: string;
296
+ size: number;
297
+ }
298
+
299
+ export const SonamuFileSchema = z.object({
300
+ name: z.string(),
301
+ url: z.string(),
302
+ mime_type: z.string(),
303
+ size: z.number(),
304
+ });
305
+
306
+ export const SonamuFileArraySchema = z.array(SonamuFileSchema);
307
+
308
+ /*
309
+ Stream
310
+ */
311
+ export type SSEStreamOptions = {
312
+ enabled?: boolean;
313
+ retry?: number;
314
+ retryInterval?: number;
315
+ };
316
+ export type SSEStreamState = {
317
+ isConnected: boolean;
318
+ error: string | null;
319
+ retryCount: number;
320
+ isEnded: boolean;
321
+ };
322
+ export type EventHandlers<T> = {
323
+ [K in keyof T]: (data: T[K]) => void;
324
+ };
325
+
326
+ import { useEffect, useRef, useState } from "react";
327
+
328
+ export function useSSEStream<T extends Record<string, any>>(
329
+ url: string,
330
+ params: Record<string, any>,
331
+ handlers: {
332
+ [K in keyof T]?: (data: T[K]) => void;
333
+ },
334
+ options: SSEStreamOptions = {},
335
+ ): SSEStreamState {
336
+ const { enabled = true, retry = 3, retryInterval = 3000 } = options;
337
+
338
+ const [state, setState] = useState<SSEStreamState>({
339
+ isConnected: false,
340
+ error: null,
341
+ retryCount: 0,
342
+ isEnded: false,
343
+ });
344
+
345
+ const eventSourceRef = useRef<EventSource | null>(null);
346
+ const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
347
+ const handlersRef = useRef(handlers);
348
+
349
+ // handlers를 ref로 관리해서 재연결 없이 업데이트
350
+ useEffect(() => {
351
+ handlersRef.current = handlers;
352
+ }, [handlers]);
353
+
354
+ // 연결 함수
355
+ const connect = () => {
356
+ if (!enabled) return;
357
+
358
+ try {
359
+ // 기존 연결이 있으면 정리
360
+ if (eventSourceRef.current) {
361
+ eventSourceRef.current.close();
362
+ eventSourceRef.current = null;
363
+ }
364
+
365
+ // 재시도 타이머 정리
366
+ if (retryTimeoutRef.current) {
367
+ clearTimeout(retryTimeoutRef.current);
368
+ retryTimeoutRef.current = null;
369
+ }
370
+
371
+ // URL에 파라미터 추가
372
+ const queryString = qs.stringify(params);
373
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
374
+
375
+ const eventSource = new EventSource(fullUrl, {
376
+ fetch: (url, init) =>
377
+ globalThis.fetch(url, {
378
+ ...init,
379
+ headers: {
380
+ ...init?.headers,
381
+ "Accept-Language": getCurrentLocale(),
382
+ },
383
+ }),
384
+ });
385
+ eventSourceRef.current = eventSource;
386
+
387
+ // 연결 시도 중 상태 표시
388
+ setState((prev) => ({
389
+ ...prev,
390
+ isConnected: false,
391
+ error: null,
392
+ isEnded: false,
393
+ }));
394
+
395
+ eventSource.onopen = () => {
396
+ setState((prev) => ({
397
+ ...prev,
398
+ isConnected: true,
399
+ error: null,
400
+ retryCount: 0,
401
+ isEnded: false,
402
+ }));
403
+ };
404
+
405
+ eventSource.onerror = (_event) => {
406
+ // 이미 다른 연결로 교체되었는지 확인
407
+ if (eventSourceRef.current !== eventSource) {
408
+ return; // 이미 새로운 연결이 있으면 무시
409
+ }
410
+
411
+ setState((prev) => ({
412
+ ...prev,
413
+ isConnected: false,
414
+ error: "Connection failed",
415
+ isEnded: false,
416
+ }));
417
+
418
+ // 자동 재연결 시도
419
+ if (state.retryCount < retry) {
420
+ retryTimeoutRef.current = setTimeout(() => {
421
+ // 여전히 같은 연결인지 확인
422
+ if (eventSourceRef.current === eventSource) {
423
+ setState((prev) => ({
424
+ ...prev,
425
+ retryCount: prev.retryCount + 1,
426
+ isEnded: false,
427
+ }));
428
+ connect();
429
+ }
430
+ }, retryInterval);
431
+ } else {
432
+ setState((prev) => ({
433
+ ...prev,
434
+ error: `Connection failed after ${retry} attempts`,
435
+ }));
436
+ }
437
+ };
438
+
439
+ // 공통 'end' 이벤트 처리 (사용자 정의 이벤트와 별도)
440
+ eventSource.addEventListener("end", () => {
441
+ console.log("SSE 연결 정상종료");
442
+ if (eventSourceRef.current === eventSource) {
443
+ eventSource.close();
444
+ eventSourceRef.current = null;
445
+ setState((prev) => ({
446
+ ...prev,
447
+ isConnected: false,
448
+ error: null, // 정상 종료
449
+ isEnded: true,
450
+ }));
451
+
452
+ if (handlersRef.current.end) {
453
+ const endHandler = handlersRef.current.end;
454
+ endHandler("end" as T[string]);
455
+ }
456
+ }
457
+ });
458
+
459
+ // 각 이벤트 타입별 리스너 등록
460
+ Object.keys(handlersRef.current).forEach((eventType) => {
461
+ const handler = handlersRef.current[eventType as keyof T];
462
+ if (handler) {
463
+ eventSource.addEventListener(eventType, (event) => {
464
+ // 여전히 현재 연결인지 확인
465
+ if (eventSourceRef.current !== eventSource) {
466
+ return; // 이미 새로운 연결로 교체되었으면 무시
467
+ }
468
+
469
+ try {
470
+ const data = JSON.parse(event.data);
471
+ handler(data);
472
+ } catch (error) {
473
+ console.error(`Failed to parse SSE data for event ${eventType}:`, error);
474
+ }
475
+ setState((prev) => ({
476
+ ...prev,
477
+ isEnded: false,
478
+ }));
479
+ });
480
+ }
481
+ });
482
+
483
+ // 기본 message 이벤트 처리 (event 타입이 없는 경우)
484
+ eventSource.onmessage = (event) => {
485
+ // 여전히 현재 연결인지 확인
486
+ if (eventSourceRef.current !== eventSource) {
487
+ return;
488
+ }
489
+
490
+ try {
491
+ const data = JSON.parse(event.data);
492
+ // 'message' 핸들러가 있으면 호출
493
+ const messageHandler = handlersRef.current["message" as keyof T];
494
+ if (messageHandler) {
495
+ messageHandler(data);
496
+ }
497
+ } catch (error) {
498
+ console.error("Failed to parse SSE message:", error);
499
+ }
500
+ };
501
+ } catch (error) {
502
+ setState((prev) => ({
503
+ ...prev,
504
+ error: error instanceof Error ? error.message : "Unknown error",
505
+ isConnected: false,
506
+ isEnded: false,
507
+ }));
508
+ }
509
+ };
510
+
511
+ // 연결 시작
512
+ useEffect(() => {
513
+ if (enabled) {
514
+ connect();
515
+ }
516
+
517
+ return () => {
518
+ // cleanup
519
+ if (eventSourceRef.current) {
520
+ eventSourceRef.current.close();
521
+ eventSourceRef.current = null;
522
+ }
523
+ if (retryTimeoutRef.current) {
524
+ clearTimeout(retryTimeoutRef.current);
525
+ retryTimeoutRef.current = null;
526
+ }
527
+ };
528
+ }, [url, JSON.stringify(params), enabled]);
529
+
530
+ // 파라미터가 변경되면 재연결
531
+ useEffect(() => {
532
+ if (enabled && eventSourceRef.current) {
533
+ connect();
534
+ }
535
+ }, [JSON.stringify(params)]);
536
+
537
+ return state;
538
+ }
539
+
540
+ /*
541
+ Dictionary Helper
542
+ */
543
+ export type PluralForms = {
544
+ zero?: string | ((n: number) => string);
545
+ one?: string | ((n: number) => string);
546
+ other?: string | ((n: number) => string);
547
+ };
548
+
549
+ export function plural(n: number, forms: PluralForms): string {
550
+ const form = (n === 0 && forms.zero) || (n === 1 && forms.one) || forms.other;
551
+ return typeof form === "function" ? form(n) : (form ?? n.toString());
552
+ }
553
+
554
+ export function createFormat(locale: string) {
555
+ return {
556
+ number: (n: number) => n.toLocaleString(locale),
557
+ date: (d: Date) => d.toLocaleDateString(locale),
558
+ };
559
+ }
560
+
561
+ export function josa(word: string, type: "은는" | "이가" | "을를" | "과와" | "으로") {
562
+ const has받침 = (() => {
563
+ const lastChar = word.charCodeAt(word.length - 1);
564
+ if (lastChar < 0xac00 || lastChar > 0xd7a3)
565
+ // 한글 유니코드 범위: 0xAC00 ~ 0xD7A3
566
+ return false;
567
+ return (lastChar - 0xac00) % 28 !== 0;
568
+ })();
569
+
570
+ const map = {
571
+ 은는: has받침 ? "은" : "는",
572
+ 이가: has받침 ? "이" : "가",
573
+ 을를: has받침 ? "을" : "를",
574
+ 과와: has받침 ? "과" : "와",
575
+ 으로: has받침 ? "으로" : "로",
576
+ };
577
+
578
+ return word + map[type];
579
+ }