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 +30 -19
- package/package.json +1 -1
- package/template/src/README.md +33 -32
- package/template/src/gitignore +1 -1
- package/template/src/packages/api/package.json +1 -1
- package/template/src/packages/api/src/sonamu.config.ts +2 -2
- package/template/src/packages/api/src/testing/setup-mocks.ts +28 -28
- package/template/src/packages/web/package.json +1 -1
- package/template/src/packages/web/src/App.tsx +3 -3
- package/template/src/packages/web/src/i18n/sd.generated.ts +1 -1
- package/template/src/packages/web/src/lib/auth-client.ts +3 -0
- package/template/src/packages/web/src/services/sonamu.shared.ts +561 -4
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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` | 개발 서버 시작 (
|
|
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
|
-
###
|
|
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
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
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
|
-
**참고**: `
|
|
218
|
+
**참고**: `sonamu build web`은 클라이언트와 SSR 서버를 모두 빌드합니다. 빌드 결과는 `web/dist/`에 생성되고, `api/web-dist/`로 복사됩니다.
|
|
208
219
|
|
|
209
220
|
---
|
|
210
221
|
|
package/package.json
CHANGED
package/template/src/README.md
CHANGED
|
@@ -48,17 +48,19 @@ cd packages/api
|
|
|
48
48
|
pnpm docker:up
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
### 3.
|
|
51
|
+
### 3. 개발 서버 시작
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
54
|
cd packages/api
|
|
55
55
|
pnpm dev
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
- **API
|
|
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.
|
|
71
|
+
### 5. 앱 확인
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
cd packages/web
|
|
73
|
-
pnpm dev
|
|
74
|
-
```
|
|
73
|
+
http://localhost:34900 을 열어서 앱을 확인하세요!
|
|
75
74
|
|
|
76
|
-
|
|
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
|
-
| 서비스
|
|
109
|
-
|
|
|
110
|
-
| API
|
|
111
|
-
| Sonamu UI
|
|
112
|
-
|
|
|
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` | 개발 서버 시작 (
|
|
130
|
-
| `pnpm build` | 프로덕션 빌드
|
|
131
|
-
| `pnpm
|
|
132
|
-
| `pnpm
|
|
133
|
-
| `pnpm
|
|
134
|
-
| `pnpm
|
|
135
|
-
| `pnpm docker:
|
|
136
|
-
| `pnpm
|
|
137
|
-
| `pnpm
|
|
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
|
-
###
|
|
141
|
+
### 개발 서버 모드
|
|
142
142
|
|
|
143
|
-
| 명령어
|
|
144
|
-
|
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
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
|
|
package/template/src/gitignore
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
),
|
|
31
|
-
writeFile: vi.fn((path: PathLike | FileHandle, data: string | Buffer | Uint8Array) => {
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
}),
|
|
36
|
-
rm: vi.fn(async (path: PathLike, options?: RmOptions) => {
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}),
|
|
39
|
+
// // Naite.t(`fs/promises:rm`, { path: filePath, options });
|
|
40
|
+
// // 실제 삭제하지 않고 기록만 함
|
|
41
|
+
// return Promise.resolve();
|
|
42
|
+
// }),
|
|
43
43
|
};
|
|
44
44
|
});
|
|
@@ -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 (
|
|
21
|
-
setLocale(browserLocale as
|
|
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]) {
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
// 자동 생성 파일 - sonamu sync로 갱신됨
|
|
2
|
-
// 초기 빈 상태 - sonamu dev 실행 시 실제 내용으로 대체됩니다.
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
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
|
+
}
|