@cartanova/qgrid-cli 1.0.1 → 1.0.2

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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * @generated
3
+ * 직접 수정하지 마세요.
4
+ */
5
+
6
+ import { Sonamu } from "sonamu";
7
+
8
+ const DEFAULT_LOCALE = "ko" as const;
9
+ const SUPPORTED_LOCALES = ["ko", "en"] as const;
10
+ function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
11
+ try {
12
+ const ctx = Sonamu.getContext();
13
+ return (ctx.locale as (typeof SUPPORTED_LOCALES)[number]) ?? DEFAULT_LOCALE;
14
+ } catch (_) {
15
+ return DEFAULT_LOCALE;
16
+ }
17
+ }
18
+
19
+ import en from "./en";
20
+ import ko from "./ko";
21
+
22
+ // react-components i18n keys
23
+ const rcKeysKo = {
24
+ // AsyncSelect Component
25
+ "rc.asyncSelect.loading": "로딩 중...",
26
+ "rc.asyncSelect.noOptions": "옵션이 없습니다",
27
+ "rc.asyncSelect.noResults": "검색 결과가 없습니다",
28
+ "rc.asyncSelect.selectPlaceholder": "선택하세요...",
29
+
30
+ // Combobox Component
31
+ "rc.combobox.noResults": "검색 결과가 없습니다",
32
+ "rc.combobox.selectPlaceholder": "선택하세요...",
33
+
34
+ // DatePicker Component
35
+ "rc.datePicker.pickDate": "날짜 선택",
36
+ "rc.datePicker.placeholder": "날짜 선택",
37
+ "rc.datePicker.selectDate": "날짜 선택",
38
+
39
+ // DateSelectorMultiple Component
40
+ "rc.dateSelectorMultiple.addDate": "날짜 추가",
41
+ "rc.dateSelectorMultiple.dateRange": "기간",
42
+ "rc.dateSelectorMultiple.placeholder": "날짜 선택",
43
+ "rc.dateSelectorMultiple.singleDate": "단일",
44
+
45
+ // FileInput Component
46
+ "rc.fileInput.browseFiles": "파일 찾기",
47
+ "rc.fileInput.dropZone": "파일을 드래그하거나 클릭하여 업로드",
48
+ "rc.fileInput.filePlaceholder": "파일",
49
+ "rc.fileInput.imagePlaceholder": "이미지",
50
+ "rc.fileInput.maxFilesExceeded": (maxFiles: number) =>
51
+ `최대 ${maxFiles}개 파일만 업로드 가능합니다`,
52
+ "rc.fileInput.pending": "대기 중",
53
+ "rc.fileInput.remove": "제거",
54
+ "rc.fileInput.uploadFailed": "업로드 실패",
55
+ "rc.fileInput.uploading": "업로드 중...",
56
+
57
+ // MonthPickerMultiple Component
58
+ "rc.monthPickerMultiple.addMonth": "월 추가",
59
+ "rc.monthPickerMultiple.dateRange": "기간",
60
+ "rc.monthPickerMultiple.endDate": "종료일",
61
+ "rc.monthPickerMultiple.placeholder": "월 선택",
62
+ "rc.monthPickerMultiple.singleDate": "단일",
63
+ "rc.monthPickerMultiple.startDate": "시작일",
64
+
65
+ // MultiSelect Component
66
+ "rc.multiSelect.clear": "전체 해제",
67
+ "rc.multiSelect.close": "닫기",
68
+ "rc.multiSelect.moreItems": (count: number) => `+${count}개 더보기`,
69
+ "rc.multiSelect.noOptions": "옵션이 없습니다",
70
+ "rc.multiSelect.noOptionsSelected": "선택된 옵션이 없습니다",
71
+ "rc.multiSelect.noResults": "검색 결과가 없습니다",
72
+ "rc.multiSelect.optionsCount": (count: number) => `${count}개 옵션`,
73
+ "rc.multiSelect.selectAll": "전체 선택",
74
+ "rc.multiSelect.selectPlaceholder": "선택하세요...",
75
+
76
+ // Pagination Component
77
+ "rc.pagination.next": "다음",
78
+ "rc.pagination.previous": "이전",
79
+ "rc.pagination.showing": (start: number, end: number, total: number) =>
80
+ `${total}개 중 ${start}-${end}`,
81
+
82
+ // Calendar Component
83
+ "rc.calendar.month.0": "1월",
84
+ "rc.calendar.month.1": "2월",
85
+ "rc.calendar.month.2": "3월",
86
+ "rc.calendar.month.3": "4월",
87
+ "rc.calendar.month.4": "5월",
88
+ "rc.calendar.month.5": "6월",
89
+ "rc.calendar.month.6": "7월",
90
+ "rc.calendar.month.7": "8월",
91
+ "rc.calendar.month.8": "9월",
92
+ "rc.calendar.month.9": "10월",
93
+ "rc.calendar.month.10": "11월",
94
+ "rc.calendar.month.11": "12월",
95
+
96
+ // Sonamu Filter Component
97
+ "rc.sonamuFilter.title": "소나무 필터",
98
+ "rc.sonamuFilter.apply": "적용",
99
+ "rc.sonamuFilter.reset": "초기화",
100
+ "rc.sonamuFilter.addRule": "규칙 추가",
101
+ "rc.sonamuFilter.noRulesYet": '아직 규칙이 없습니다. "+ 규칙 추가"를 클릭하여 시작하세요.',
102
+ "rc.sonamuFilter.selectField": "필드 선택",
103
+ "rc.sonamuFilter.selectOperator": "연산자 선택",
104
+ "rc.sonamuFilter.selectOperatorFirst": "먼저 연산자를 선택하세요",
105
+ "rc.sonamuFilter.enterValue": "값 입력",
106
+ "rc.sonamuFilter.enterNumber": "숫자 입력",
107
+ "rc.sonamuFilter.notSupported": "지원하지 않음",
108
+ "rc.sonamuFilter.startDate": "시작일",
109
+ "rc.sonamuFilter.endDate": "종료일",
110
+ "rc.sonamuFilter.appliedFilters": "🌲 적용된 소나무 필터",
111
+
112
+ // Common
113
+ "rc.common.cancel": "취소",
114
+ "rc.common.save": "저장",
115
+ };
116
+
117
+ const rcKeysEn = {
118
+ // AsyncSelect Component
119
+ "rc.asyncSelect.loading": "Loading...",
120
+ "rc.asyncSelect.noOptions": "No options",
121
+ "rc.asyncSelect.noResults": "No results",
122
+ "rc.asyncSelect.selectPlaceholder": "Select...",
123
+
124
+ // Combobox Component
125
+ "rc.combobox.noResults": "No results",
126
+ "rc.combobox.selectPlaceholder": "Select...",
127
+
128
+ // DatePicker Component
129
+ "rc.datePicker.pickDate": "Pick date",
130
+ "rc.datePicker.placeholder": "Pick date",
131
+ "rc.datePicker.selectDate": "Select date",
132
+
133
+ // DateSelectorMultiple Component
134
+ "rc.dateSelectorMultiple.addDate": "Add date",
135
+ "rc.dateSelectorMultiple.dateRange": "Range",
136
+ "rc.dateSelectorMultiple.placeholder": "Pick date",
137
+ "rc.dateSelectorMultiple.singleDate": "Single",
138
+
139
+ // FileInput Component
140
+ "rc.fileInput.browseFiles": "Browse files",
141
+ "rc.fileInput.dropZone": "Drag files here or click to upload",
142
+ "rc.fileInput.filePlaceholder": "File",
143
+ "rc.fileInput.imagePlaceholder": "Image",
144
+ "rc.fileInput.maxFilesExceeded": (maxFiles: number) => `Maximum ${maxFiles} files allowed`,
145
+ "rc.fileInput.pending": "Pending",
146
+ "rc.fileInput.remove": "Remove",
147
+ "rc.fileInput.uploadFailed": "Upload failed",
148
+ "rc.fileInput.uploading": "Uploading...",
149
+
150
+ // MonthPickerMultiple Component
151
+ "rc.monthPickerMultiple.addMonth": "Add month",
152
+ "rc.monthPickerMultiple.dateRange": "Range",
153
+ "rc.monthPickerMultiple.endDate": "End date",
154
+ "rc.monthPickerMultiple.placeholder": "Pick month",
155
+ "rc.monthPickerMultiple.singleDate": "Single",
156
+ "rc.monthPickerMultiple.startDate": "Start date",
157
+
158
+ // MultiSelect Component
159
+ "rc.multiSelect.clear": "Clear all",
160
+ "rc.multiSelect.close": "Close",
161
+ "rc.multiSelect.moreItems": (count: number) => `+${count} more`,
162
+ "rc.multiSelect.noOptions": "No options",
163
+ "rc.multiSelect.noOptionsSelected": "No options selected",
164
+ "rc.multiSelect.noResults": "No results",
165
+ "rc.multiSelect.optionsCount": (count: number) => `${count} options`,
166
+ "rc.multiSelect.selectAll": "Select all",
167
+ "rc.multiSelect.selectPlaceholder": "Select...",
168
+
169
+ // Pagination Component
170
+ "rc.pagination.next": "Next",
171
+ "rc.pagination.previous": "Previous",
172
+ "rc.pagination.showing": (start: number, end: number, total: number) =>
173
+ `Showing ${start}-${end} of ${total}`,
174
+
175
+ // Calendar Component
176
+ "rc.calendar.month.0": "January",
177
+ "rc.calendar.month.1": "February",
178
+ "rc.calendar.month.2": "March",
179
+ "rc.calendar.month.3": "April",
180
+ "rc.calendar.month.4": "May",
181
+ "rc.calendar.month.5": "June",
182
+ "rc.calendar.month.6": "July",
183
+ "rc.calendar.month.7": "August",
184
+ "rc.calendar.month.8": "September",
185
+ "rc.calendar.month.9": "October",
186
+ "rc.calendar.month.10": "November",
187
+ "rc.calendar.month.11": "December",
188
+
189
+ // Sonamu Filter Component
190
+ "rc.sonamuFilter.title": "Sonamu Filter",
191
+ "rc.sonamuFilter.apply": "Apply",
192
+ "rc.sonamuFilter.reset": "Reset",
193
+ "rc.sonamuFilter.addRule": "Add Rule",
194
+ "rc.sonamuFilter.noRulesYet": 'No rules yet. Click "+ Add Rule" to start.',
195
+ "rc.sonamuFilter.selectField": "Select field",
196
+ "rc.sonamuFilter.selectOperator": "Operator",
197
+ "rc.sonamuFilter.selectOperatorFirst": "Select operator first",
198
+ "rc.sonamuFilter.enterValue": "Enter value",
199
+ "rc.sonamuFilter.enterNumber": "Enter number",
200
+ "rc.sonamuFilter.notSupported": "Not supported",
201
+ "rc.sonamuFilter.startDate": "Start date",
202
+ "rc.sonamuFilter.endDate": "End date",
203
+ "rc.sonamuFilter.appliedFilters": "🌲 Applied SonamuFilters",
204
+
205
+ // Common
206
+ "rc.common.cancel": "Cancel",
207
+ "rc.common.save": "Save",
208
+ } as const;
209
+
210
+ // entity.json에서 추출한 entity labels (defaultLocale 전용)
211
+ const entityLabels = {
212
+ "entity.RequestLog": "RequestLog",
213
+ "entity.RequestLog.id": "ID",
214
+ "entity.RequestLog.created_at": "등록일시",
215
+ "entity.RequestLog.token_name": "토큰 이름",
216
+ "entity.RequestLog.query": "prompt 전문",
217
+ "entity.RequestLog.response": "llm 응답 전문",
218
+ "entity.RequestLog.input_tokens": "입력 토큰",
219
+ "entity.RequestLog.output_tokens": "출력 토큰",
220
+ "entity.RequestLog.cache_read_tokens": "캐시 읽기 토큰",
221
+ "entity.RequestLog.cache_creation_tokens": "캐시 생성 토큰",
222
+ "entity.RequestLog.duration_ms": "응답 소요시간(ms)",
223
+ "enum.RequestLogOrderBy.id-desc": "ID최신순",
224
+ "enum.RequestLogSearchField.id": "ID",
225
+ "enum.RequestLogSearchField.token_name": "토큰이름",
226
+ "enum.RequestLogSearchField.query": "쿼리",
227
+ "entity.Token": "Token",
228
+ "entity.Token.id": "ID",
229
+ "entity.Token.created_at": "등록일시",
230
+ "entity.Token.token": "access_token",
231
+ "entity.Token.name": "토큰 이름",
232
+ "entity.Token.refresh_token": "refresh_token",
233
+ "entity.Token.expires_at": "만료 시각 (epoch ms)",
234
+ "entity.Token.account_uuid": "Anthropic 계정 UUID",
235
+ "entity.Token.active": "활성 여부",
236
+ "enum.TokenOrderBy.id-desc": "ID최신순",
237
+ "enum.TokenSearchField.id": "ID",
238
+ "enum.TokenSearchField.name": "이름",
239
+ } as const;
240
+
241
+ // defaultLocale의 dictionary를 기준으로 키 추출
242
+ type RCKeys = typeof rcKeysKo;
243
+ type ProjectDictionary = typeof ko;
244
+ type EntityLabels = typeof entityLabels;
245
+ type RawMergedDictionary = RCKeys &
246
+ Omit<EntityLabels, keyof (RCKeys & ProjectDictionary)> &
247
+ ProjectDictionary;
248
+
249
+ // 키는 유지하되, 값 타입은 string 또는 함수로 일반화 (다른 locale의 리터럴 타입 충돌 방지)
250
+ export type MergedDictionary = {
251
+ [K in keyof RawMergedDictionary]: RawMergedDictionary[K] extends (...args: infer P) => string
252
+ ? (...args: P) => string
253
+ : string;
254
+ };
255
+ export type DictKey = keyof MergedDictionary;
256
+ export type LocalizedString = string & { __brand: "LocalizedString" };
257
+
258
+ export function defineLocale(dict: Partial<MergedDictionary>) {
259
+ return dict;
260
+ }
261
+
262
+ // 각 locale별로 rc-keys + entity labels + 프로젝트 dict 합침
263
+ const dictionaries: Record<string, Partial<MergedDictionary>> = {
264
+ ko: { ...rcKeysKo, ...entityLabels, ...ko },
265
+ en: { ...rcKeysEn, ...en },
266
+ };
267
+
268
+ type SDReturnType<K extends DictKey> = MergedDictionary[K] extends (...args: infer P) => string
269
+ ? (...args: P) => LocalizedString
270
+ : LocalizedString;
271
+
272
+ function getDictValue<K extends DictKey>(key: K, locale: string): SDReturnType<K> {
273
+ // 1. 지정된 locale에서 조회
274
+ const dict = dictionaries[locale];
275
+ if (dict?.[key] !== undefined) {
276
+ return dict[key] as unknown as SDReturnType<K>;
277
+ }
278
+
279
+ // 2. default locale에서 조회
280
+ if (locale !== DEFAULT_LOCALE && dictionaries[DEFAULT_LOCALE]?.[key] !== undefined) {
281
+ return dictionaries[DEFAULT_LOCALE][key] as unknown as SDReturnType<K>;
282
+ }
283
+
284
+ // 3. supported locales 순회
285
+ for (const supportedLocale of SUPPORTED_LOCALES) {
286
+ if (supportedLocale !== locale && supportedLocale !== DEFAULT_LOCALE) {
287
+ if (dictionaries[supportedLocale]?.[key] !== undefined) {
288
+ return dictionaries[supportedLocale][key] as unknown as SDReturnType<K>;
289
+ }
290
+ }
291
+ }
292
+
293
+ // 4. 모두 실패 시 key 반환
294
+ return key as unknown as SDReturnType<K>;
295
+ }
296
+
297
+ /**
298
+ * Sonamu Dictionary 함수
299
+ * locale에 맞는 번역 텍스트를 반환합니다.
300
+ *
301
+ * @example
302
+ * SD("common.save") // → "저장" (LocalizedString)
303
+ * SD("user.notFound")(1) // → "존재하지 않는 User ID 1" (LocalizedString)
304
+ */
305
+ export function SD<K extends DictKey>(key: K): SDReturnType<K> {
306
+ const locale = getCurrentLocale();
307
+ return getDictValue(key, locale);
308
+ }
309
+
310
+ /**
311
+ * 특정 locale의 번역 텍스트를 반환하는 함수를 생성합니다.
312
+ *
313
+ * @example
314
+ * const EN = SD.locale("en");
315
+ * EN("common.save") // → "Save"
316
+ */
317
+ SD.locale =
318
+ (locale: (typeof SUPPORTED_LOCALES)[number]) =>
319
+ <K extends DictKey>(key: K): SDReturnType<K> => {
320
+ return getDictValue(key, locale);
321
+ };
322
+
323
+ // Localized 가능한 Column 타입 계산
324
+ type LocalizedBaseColumn<T> = {
325
+ [K in keyof T & string]: K extends `${infer Base}_${(typeof SUPPORTED_LOCALES)[number]}`
326
+ ? Base
327
+ : K;
328
+ }[keyof T & string];
329
+
330
+ /**
331
+ * locale에 따라 적절한 컬럼 값을 반환합니다.
332
+ * DB에 name, name_ko, name_en처럼 localized column이 있을 때 사용합니다.
333
+ *
334
+ * 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 ko, 사용자의 로케일은 jp일 때): column_jp → column → column_ko → column_en
335
+ * 우선순위 (지원 로케일은 ko/jp/en이고, 서비스의 기본 로케일은 en, 사용자의 로케일은 ko일 때): column_ko → column → column_en → column_jp
336
+ *
337
+ * @example
338
+ * localizedColumn(tag, "name")
339
+ */
340
+ export function localizedColumn<
341
+ T extends Record<string, unknown>,
342
+ K extends LocalizedBaseColumn<T>,
343
+ >(row: T, column: K): string | undefined {
344
+ const locale = getCurrentLocale();
345
+ const otherLocales = SUPPORTED_LOCALES.filter(
346
+ (l: string) => l !== locale && l !== DEFAULT_LOCALE,
347
+ );
348
+ const localizedKey = (column: K, locale: (typeof SUPPORTED_LOCALES)[number]) =>
349
+ `${column}_${locale}`;
350
+ const keys = [
351
+ localizedKey(column, locale),
352
+ column,
353
+ localizedKey(column, DEFAULT_LOCALE),
354
+ ...otherLocales.map((l) => localizedKey(column, l)),
355
+ ];
356
+
357
+ for (const key of keys) {
358
+ if (!(key in row)) {
359
+ continue;
360
+ }
361
+
362
+ if (row[key] !== null && row[key] !== undefined && row[key] !== "") {
363
+ return String(row[key]);
364
+ }
365
+ }
366
+
367
+ return undefined;
368
+ }
369
+
370
+ /**
371
+ * Enum의 localized labels를 Proxy로 반환합니다.
372
+ * Select 컴포넌트 등에서 EnumLabel[key] 대신 사용합니다.
373
+ *
374
+ * @example
375
+ * SD.enumLabels("TagOrderBy")[key] // → 현재 locale의 라벨
376
+ */
377
+ SD.enumLabels = (enumName: string): Record<string, LocalizedString> => {
378
+ return new Proxy({} as Record<string, LocalizedString>, {
379
+ get(_, key: string) {
380
+ const dictKey = `enum.${enumName}.${key}` as DictKey;
381
+ return getDictValue(dictKey, getCurrentLocale());
382
+ },
383
+ });
384
+ };
@@ -0,0 +1,6 @@
1
+ import { Sonamu } from "sonamu";
2
+
3
+ async function bootstrap() {
4
+ await Sonamu.createServer();
5
+ }
6
+ bootstrap();
@@ -0,0 +1,248 @@
1
+ import path from "path";
2
+
3
+ import { getConsoleSink } from "@logtape/logtape";
4
+ import { getPrettyFormatter } from "@logtape/pretty";
5
+ import dotenv from "dotenv";
6
+ import { CachePresets, defineConfig } from "sonamu";
7
+ import { drivers as cacheDrivers, store } from "sonamu/cache";
8
+ import { drivers } from "sonamu/storage";
9
+
10
+ dotenv.config({ path: path.join(import.meta.dirname, "../.env") });
11
+
12
+ const host = process.env.HOST ?? "localhost";
13
+ const port = Number(process.env.PORT ?? 44900);
14
+
15
+ export default defineConfig({
16
+ projectName: process.env.PROJECT_NAME ?? "SonamuProject",
17
+ database: {
18
+ name: process.env.QGRID_DB_NAME ?? "qgrid",
19
+ defaultOptions: {
20
+ connection: {
21
+ host: process.env.QGRID_DB_HOST ?? "localhost",
22
+ port: Number(process.env.QGRID_DB_PORT ?? 44901),
23
+ user: process.env.QGRID_DB_USER ?? "postgres",
24
+ password: process.env.QGRID_DB_PASSWORD ?? "postgres",
25
+ },
26
+ },
27
+ environments: {
28
+ production: {
29
+ connection: {
30
+ host: process.env.DEV0_DB_HOST,
31
+ port: Number(process.env.DEV0_DB_PORT),
32
+ database: process.env.DATABASE_NAME,
33
+ user: process.env.DEV0_DB_USER,
34
+ password: process.env.DEV0_DB_PASSWORD,
35
+ },
36
+ },
37
+ fixture: {
38
+ connection: {
39
+ host: process.env.DEV0_DB_HOST,
40
+ port: Number(process.env.DEV0_DB_PORT),
41
+ database: process.env.DATABASE_NAME,
42
+ user: process.env.DEV0_DB_USER,
43
+ password: process.env.DEV0_DB_PASSWORD,
44
+ },
45
+ },
46
+ },
47
+ },
48
+ api: {
49
+ dir: "api",
50
+ timezone: "Asia/Seoul",
51
+ route: {
52
+ prefix: "/api",
53
+ },
54
+ },
55
+ i18n: {
56
+ defaultLocale: "ko",
57
+ supportedLocales: ["ko", "en"],
58
+ },
59
+ sync: {
60
+ targets: ["web"],
61
+ },
62
+ slackConfirm:
63
+ process.env.SLACK_BOT_TOKEN && process.env.SLACK_CHANNEL_ID
64
+ ? {
65
+ targets: ["development_master", "production_master"],
66
+ botToken: process.env.SLACK_BOT_TOKEN ?? "",
67
+ channelId: process.env.SLACK_CHANNEL_ID ?? "",
68
+ }
69
+ : undefined,
70
+ test: {
71
+ parallel: true,
72
+ maxWorkers: 4,
73
+ devRunner: { enabled: true },
74
+ },
75
+ logging: {
76
+ sinks: {
77
+ console: getConsoleSink({
78
+ formatter: getPrettyFormatter({
79
+ timestamp: "time",
80
+ categoryWidth: 20,
81
+ categoryTruncate: "middle",
82
+ }),
83
+ }),
84
+ },
85
+ loggers: [
86
+ {
87
+ category: ["sonamu"],
88
+ sinks: ["console"],
89
+ lowestLevel: process.env.NODE_ENV === "test" ? "warning" : "debug",
90
+ },
91
+ ],
92
+ },
93
+ server: {
94
+ listen: { port, host },
95
+ plugins: {
96
+ formbody: true,
97
+ qs: true,
98
+ multipart: { limits: { fileSize: 1024 * 1024 * 30 } },
99
+ static: {
100
+ root: path.join(import.meta.dirname, "/../", "public"),
101
+ prefix: "/api/public",
102
+ },
103
+ custom: (server) => {
104
+ // OAuth 콜백 — Anthropic이 /callback으로 리다이렉트
105
+ server.get("/callback", async (request, reply) => {
106
+ const { code, state } = request.query as { code?: string; state?: string };
107
+ if (!code || !state) {
108
+ return reply.redirect("/?oauth=error&reason=missing_params");
109
+ }
110
+ // Frame의 oauthCallback으로 위임
111
+ try {
112
+ const { QgridFrame } = await import("./application/qgrid/qgrid.frame");
113
+ await QgridFrame.handleOAuthCallback(code, state, reply);
114
+ } catch (e) {
115
+ return reply.redirect(
116
+ `/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`,
117
+ );
118
+ }
119
+ });
120
+ },
121
+ },
122
+ apiConfig: {
123
+ contextProvider: (defaultContext, request) => {
124
+ return {
125
+ ...defaultContext,
126
+ ip: request.ip,
127
+ body: request.body,
128
+ };
129
+ },
130
+ guardHandler: (_guard, _request, _api) => {
131
+ if (_guard === "user") {
132
+ console.log("user guard");
133
+ }
134
+ console.log("NOTHING YET");
135
+ },
136
+ cacheControlHandler: (req) => {
137
+ switch (req.type) {
138
+ case "assets":
139
+ // Hash 포함된 파일: 영구 캐시
140
+ if (req.path.match(/-[a-f0-9]+\./)) {
141
+ return CachePresets.immutable;
142
+ }
143
+ return CachePresets.longLived;
144
+
145
+ case "api":
146
+ // GET 요청만 캐싱 고려
147
+ if (req.method === "GET") {
148
+ // 특정 경로는 짧은 캐시
149
+ if (req.path.startsWith("/api/static-data")) {
150
+ return CachePresets.shortLived;
151
+ }
152
+ if (req.path.startsWith("/api/terms")) {
153
+ return CachePresets.mediumLived;
154
+ }
155
+ }
156
+ // 기본: 캐시 없음
157
+ return CachePresets.noCache;
158
+
159
+ case "ssr":
160
+ // SSR 페이지: 10초 캐시
161
+ return CachePresets.ssr;
162
+
163
+ case "csr":
164
+ // CSR fallback (index.html): 1분 캐시
165
+ return CachePresets.shortLived;
166
+ }
167
+ },
168
+ },
169
+ storage: {
170
+ drivers: {
171
+ fs: drivers.fs({
172
+ location: path.join(import.meta.dirname, "/../public/uploaded"),
173
+ visibility: "public",
174
+ urlBuilder: {
175
+ async generateURL(key) {
176
+ return `/api/public/uploaded/${key}`;
177
+ },
178
+ async generateSignedURL(key) {
179
+ return `/api/public/uploaded/${key}`;
180
+ },
181
+ },
182
+ }),
183
+ s3: drivers.s3({
184
+ credentials: {
185
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "",
186
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "",
187
+ },
188
+ region: process.env.S3_REGION ?? "ap-northeast-2",
189
+ bucket: process.env.S3_BUCKET ?? "sonamu_default_bucket",
190
+ visibility: "private",
191
+ }),
192
+ },
193
+ },
194
+ cache: {
195
+ default: "main",
196
+ stores: {
197
+ main: store().useL1Layer(cacheDrivers.memory({ maxSize: "50mb" })),
198
+ },
199
+ ttl: "5m",
200
+ prefix: "",
201
+ },
202
+ lifecycle: {
203
+ onStart: async () => {
204
+ // DB 마이그레이션 자동 실행 (테이블 없으면 생성)
205
+ try {
206
+ const { TokenModel } = await import("./application/token/token.model");
207
+ const knex = TokenModel.getDB("w");
208
+ const migrationsDir = path.join(import.meta.dirname, "../src/migrations");
209
+ const [batch, log] = await knex.migrate.latest({
210
+ directory: migrationsDir,
211
+ });
212
+ if (log.length > 0) {
213
+ console.log(`✓ Migration: ${log.length} applied (batch ${batch})`);
214
+ log.forEach((name: string) => console.log(` ✓ ${name}`));
215
+ }
216
+ } catch (e) {
217
+ console.warn(`⚠ Migration skipped: ${(e as Error).message}`);
218
+ }
219
+
220
+ // Pool 초기화
221
+ const { initPool } = await import("./application/qgrid/pool");
222
+ try {
223
+ const { TokenModel } = await import("./application/token/token.model");
224
+ const entries = await TokenModel.findActive("A");
225
+ const tokens = entries.map((e) => e.token);
226
+ const pool = initPool(tokens);
227
+ console.log(
228
+ `🌲 Server listening on http://${host}:${port} (${pool.workers.size} tokens loaded)`,
229
+ );
230
+ } catch (e) {
231
+ console.warn(`⚠ Pool init failed: ${(e as Error).message}`);
232
+ initPool([]);
233
+ console.log(`🌲 Server listening on http://${host}:${port} (no tokens)`);
234
+ }
235
+ },
236
+ onShutdown: () => {
237
+ console.log("graceful shutdown");
238
+ },
239
+ onError: (error, _request, reply) => {
240
+ console.error(error);
241
+ reply.status(500).send({
242
+ name: error.name,
243
+ message: error.message,
244
+ });
245
+ },
246
+ },
247
+ },
248
+ });
@@ -0,0 +1,6 @@
1
+ import { createFixtureLoader } from "sonamu/test";
2
+
3
+ export const loadFixtures = createFixtureLoader({
4
+ // Define your fixtures...
5
+ fixture01: async () => {},
6
+ });
@@ -0,0 +1,6 @@
1
+ import dotenv from "dotenv";
2
+
3
+ dotenv.config();
4
+
5
+ // sonamu.config.ts의 test 설정을 기반으로 병렬 테스트 환경을 구성합니다.
6
+ export { setup } from "sonamu/test";