sonamu 0.9.3 → 0.9.5
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/dist/ai/providers/rtzr/utils.js +2 -2
- package/dist/api/config.d.ts +0 -8
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/sonamu.d.ts +0 -1
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +2 -41
- package/dist/auth/audit-log/builders.d.ts +216 -0
- package/dist/auth/audit-log/builders.d.ts.map +1 -0
- package/dist/auth/audit-log/builders.js +307 -0
- package/dist/auth/audit-log/events.d.ts +143 -0
- package/dist/auth/audit-log/events.d.ts.map +1 -0
- package/dist/auth/audit-log/events.js +74 -0
- package/dist/auth/audit-log/plugin.d.ts +11 -0
- package/dist/auth/audit-log/plugin.d.ts.map +1 -0
- package/dist/auth/audit-log/plugin.js +427 -0
- package/dist/auth/audit-log-ingestor.d.ts +3 -3
- package/dist/auth/audit-log-ingestor.d.ts.map +1 -1
- package/dist/auth/audit-log-ingestor.js +44 -50
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +4 -4
- package/dist/auth/plugins/entity-definitions/admin.d.ts +1 -1
- package/dist/auth/plugins/entity-definitions/admin.js +4 -4
- package/dist/auth/plugins/entity-definitions/audit-log.d.ts +2 -2
- package/dist/auth/plugins/entity-definitions/audit-log.js +3 -3
- package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
- package/dist/auth/plugins/wrappers/sso.d.ts +1 -1
- package/dist/bin/fixture.d.ts.map +1 -1
- package/dist/bin/fixture.js +111 -1
- package/dist/database/_batch_update.d.ts +1 -1
- package/dist/database/_batch_update.js +2 -2
- package/dist/database/upsert-builder.js +4 -4
- package/dist/dict/sonamu-dictionary.js +2 -2
- package/dist/entity/entity-manager.d.ts +2 -2
- package/dist/entity/entity-manager.d.ts.map +1 -1
- package/dist/entity/entity-manager.js +14 -4
- package/dist/index.js +4 -3
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +2 -3
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +2 -9
- package/dist/template/implementations/entry-server.template.js +3 -2
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +2 -1
- package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
- package/dist/template/implementations/generated_sso.template.js +2 -1
- package/dist/template/implementations/queries.template.d.ts.map +1 -1
- package/dist/template/implementations/queries.template.js +3 -1
- package/dist/template/implementations/sd.template.js +3 -2
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +44 -7
- package/dist/template/zod-converter.d.ts.map +1 -1
- package/dist/template/zod-converter.js +2 -2
- package/dist/testing/data-explorer.d.ts.map +1 -1
- package/dist/testing/data-explorer.js +5 -3
- package/dist/types/types.d.ts +14 -14
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +3 -2
- package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
- package/dist/ui-web/assets/{index-DrTfl0Ts.js → index-DzZ7vBk4.js} +47 -47
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +4 -4
- package/package.json +4 -5
- package/src/ai/providers/rtzr/utils.ts +1 -1
- package/src/api/config.ts +0 -8
- package/src/api/sonamu.ts +1 -51
- package/src/auth/audit-log/builders.ts +791 -0
- package/src/auth/audit-log/events.ts +149 -0
- package/src/auth/audit-log/plugin.ts +913 -0
- package/src/auth/audit-log-ingestor.ts +3 -4
- package/src/auth/index.ts +2 -0
- package/src/auth/plugins/entity-definitions/admin.ts +3 -3
- package/src/auth/plugins/entity-definitions/audit-log.ts +2 -2
- package/src/bin/fixture.ts +143 -0
- package/src/database/_batch_update.ts +1 -1
- package/src/database/upsert-builder.ts +3 -3
- package/src/dict/sonamu-dictionary.ts +1 -1
- package/src/entity/entity-manager.ts +10 -3
- package/src/migration/code-generation.ts +1 -6
- package/src/shared/app.shared.ts.txt +60 -6
- package/src/shared/web.shared.ts.txt +60 -5
- package/src/syncer/syncer.ts +1 -11
- package/src/template/implementations/entry-server.template.ts +1 -1
- package/src/template/implementations/generated.template.ts +1 -0
- package/src/template/implementations/generated_sso.template.ts +1 -0
- package/src/template/implementations/queries.template.ts +10 -1
- package/src/template/implementations/sd.template.ts +1 -1
- package/src/template/implementations/services.template.ts +62 -6
- package/src/template/zod-converter.ts +2 -1
- package/src/testing/data-explorer.ts +3 -2
- package/src/ui/api.ts +10 -1
- package/src/utils/fs-utils.ts +6 -4
- package/dist/auth/audit-log-proxy-types.d.ts +0 -23
- package/dist/auth/audit-log-proxy-types.d.ts.map +0 -1
- package/dist/auth/audit-log-proxy-types.js +0 -1
- package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
- package/src/auth/audit-log-proxy-types.ts +0 -23
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { type Knex } from "knex";
|
|
4
4
|
|
|
5
|
-
import { type AuditLogEvent } from "./audit-log
|
|
5
|
+
import { type AuditLogEvent } from "./audit-log/events";
|
|
6
6
|
|
|
7
7
|
const AUDIT_EVENT_SOURCE = "better_auth";
|
|
8
8
|
const AUDIT_EVENT_SOURCE_VERSION = "better-auth|@better-auth/infra";
|
|
@@ -48,7 +48,6 @@ const USER_EVENT_TYPES = new Set<string>([
|
|
|
48
48
|
"profile_updated",
|
|
49
49
|
"profile_image_updated",
|
|
50
50
|
"email_verified",
|
|
51
|
-
"email_changed",
|
|
52
51
|
"user_banned",
|
|
53
52
|
"user_unbanned",
|
|
54
53
|
"user_deleted",
|
|
@@ -139,9 +138,9 @@ function computeDedupeKey(parts: {
|
|
|
139
138
|
}
|
|
140
139
|
|
|
141
140
|
/**
|
|
142
|
-
*
|
|
141
|
+
* sonamuAuditLog 플러그인이 구성한 AuditLogEvent를 audit_events 테이블에 적재합니다.
|
|
143
142
|
* ON CONFLICT (dedupe_key) DO NOTHING으로 중복을 silent 무시합니다.
|
|
144
|
-
* auth.
|
|
143
|
+
* auth.plugins에 sonamuAuditLog() 추가 시 sonamu 내부에서 자동으로 호출됩니다.
|
|
145
144
|
*/
|
|
146
145
|
export async function ingestAuditEvent(db: Knex, event: AuditLogEvent): Promise<void> {
|
|
147
146
|
const eventData = event.eventData;
|
package/src/auth/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { GenerateBetterAuthEntitiesOptions } from "./auth-generator";
|
|
2
2
|
export { generateBetterAuthEntities } from "./auth-generator";
|
|
3
3
|
export { ingestAuditEvent } from "./audit-log-ingestor";
|
|
4
|
+
export { type AuditLogEvent } from "./audit-log/events";
|
|
5
|
+
export { sonamuAuditLog } from "./audit-log/plugin";
|
|
4
6
|
export { BASE_FIELD_MAPPINGS, betterAuthV1 } from "./better-auth-entities";
|
|
5
7
|
export { sonamuKnexAdapter } from "./knex-adapter";
|
|
6
8
|
|
|
@@ -10,7 +10,7 @@ import { type BetterAuthEntityDef } from "./types";
|
|
|
10
10
|
* - role: 사용자 역할 (기본값: "user")
|
|
11
11
|
* - banned: 차단 여부
|
|
12
12
|
* - ban_reason: 차단 사유
|
|
13
|
-
* - ban_expires: 차단 만료 시간 (
|
|
13
|
+
* - ban_expires: 차단 만료 시간 (Date)
|
|
14
14
|
*
|
|
15
15
|
* Session 테이블:
|
|
16
16
|
* - impersonated_by: 대리 로그인한 관리자 ID
|
|
@@ -43,9 +43,9 @@ export const adminEntityDef: BetterAuthEntityDef = {
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
name: "ban_expires",
|
|
46
|
-
type: "
|
|
46
|
+
type: "date",
|
|
47
47
|
nullable: true,
|
|
48
|
-
desc: "차단 만료
|
|
48
|
+
desc: "차단 만료",
|
|
49
49
|
},
|
|
50
50
|
],
|
|
51
51
|
Session: [
|
|
@@ -3,8 +3,8 @@ import { type BetterAuthEntityDef } from "./types";
|
|
|
3
3
|
/**
|
|
4
4
|
* better-auth AuditLog 플러그인 엔티티 정의
|
|
5
5
|
*
|
|
6
|
-
* auth.
|
|
7
|
-
* - Better Auth
|
|
6
|
+
* auth.plugins에 sonamuAuditLog() 추가 시 audit_events 테이블이 사용됩니다.
|
|
7
|
+
* - sonamuAuditLog 플러그인이 Better Auth databaseHooks/organizationHooks/middleware에서 수신한 이벤트를 1건씩 적재합니다.
|
|
8
8
|
* - dedupe_key(sha256 hex)로 중복 적재를 방지합니다.
|
|
9
9
|
*
|
|
10
10
|
* 생성 방법: pnpm sonamu auth generate --plugins audit-log
|
package/src/bin/fixture.ts
CHANGED
|
@@ -22,6 +22,23 @@ interface FixtureCommandOptions {
|
|
|
22
22
|
"no-cache"?: boolean;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* username을 일반적인 규칙(영문자 시작, 영문자/숫자만, 1-20자)에 맞도록 정규화합니다.
|
|
27
|
+
*/
|
|
28
|
+
function sanitizeUsername(raw: string): string {
|
|
29
|
+
// 소문자 변환 후 영문자/숫자 외 문자 제거
|
|
30
|
+
let username = raw.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
31
|
+
if (username.length === 0) {
|
|
32
|
+
username = "user";
|
|
33
|
+
}
|
|
34
|
+
// 첫 글자가 숫자면 'u' 접두어 추가
|
|
35
|
+
if (/^[0-9]/.test(username)) {
|
|
36
|
+
username = `u${username}`;
|
|
37
|
+
}
|
|
38
|
+
// 20자 초과 시 truncate
|
|
39
|
+
return username.slice(0, 20);
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
/**
|
|
26
43
|
* fixture gen 명령어 - cone 메타데이터를 활용하여 자동으로 fixture를 생성합니다.
|
|
27
44
|
*/
|
|
@@ -76,6 +93,132 @@ export async function fixtureGenCommand(options: FixtureCommandOptions) {
|
|
|
76
93
|
count = result.count;
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
// User 엔티티가 포함된 경우: 로그인 가능/확인용 분기 선택
|
|
97
|
+
const hasUser = entityNames.includes("User");
|
|
98
|
+
|
|
99
|
+
if (hasUser) {
|
|
100
|
+
const userModeResult = await prompts({
|
|
101
|
+
type: "select",
|
|
102
|
+
name: "userMode",
|
|
103
|
+
message: "User 엔티티가 포함되어 있습니다. 생성 방식을 선택하세요:",
|
|
104
|
+
choices: [
|
|
105
|
+
{ title: "1. 로그인 가능한 사용자 fixture 생성", value: "login" },
|
|
106
|
+
{ title: "2. 확인용 데이터(로그인 불가) 생성", value: "dummy" },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!userModeResult.userMode) {
|
|
111
|
+
console.log(chalk.yellow("취소되었습니다."));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (userModeResult.userMode === "login") {
|
|
116
|
+
// LLM 사용 여부
|
|
117
|
+
let useLLM = options["use-llm"] ?? false;
|
|
118
|
+
if (!options["use-llm"]) {
|
|
119
|
+
const llmResult = await prompts({
|
|
120
|
+
type: "confirm",
|
|
121
|
+
name: "useLLM",
|
|
122
|
+
message:
|
|
123
|
+
"LLM으로 더 현실적인 데이터를 생성할까요? (fixtureHint 기반, ANTHROPIC_API_KEY 필요)",
|
|
124
|
+
initial: false,
|
|
125
|
+
});
|
|
126
|
+
useLLM = llmResult.useLLM ?? false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const enableLLMCache = !options["no-cache"];
|
|
130
|
+
const DEFAULT_PASSWORD = "Test1234!";
|
|
131
|
+
|
|
132
|
+
// 로그인 가능 경로에서는 sourceDb로 development_master 사용
|
|
133
|
+
const sourceDb = DB.getDB("r");
|
|
134
|
+
const generator = new FixtureGenerator(
|
|
135
|
+
sourceDb,
|
|
136
|
+
sourceDb,
|
|
137
|
+
"production_master",
|
|
138
|
+
EntityManager,
|
|
139
|
+
{ useLLM, enableLLMCache },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const createdCredentials: Array<{ email: string; password: string }> = [];
|
|
143
|
+
const basePath = Sonamu.config.server.auth?.basePath ?? "/api/auth";
|
|
144
|
+
|
|
145
|
+
if (useLLM) {
|
|
146
|
+
console.log(
|
|
147
|
+
chalk.cyan(
|
|
148
|
+
`\nLLM 모드로 로그인 가능한 사용자 ${count}명 생성 중... (캐싱: ${enableLLMCache ? "ON" : "OFF"})`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
console.log(chalk.cyan(`\n로그인 가능한 사용자 ${count}명 생성 중...`));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < count; i++) {
|
|
156
|
+
const userData = await generator.generate("User");
|
|
157
|
+
|
|
158
|
+
const name = String(userData.name ?? "");
|
|
159
|
+
const email = String(userData.email ?? "");
|
|
160
|
+
const username = sanitizeUsername(String(userData.username ?? ""));
|
|
161
|
+
const displayUsername =
|
|
162
|
+
userData.display_username !== undefined ? String(userData.display_username) : undefined;
|
|
163
|
+
|
|
164
|
+
const body: Record<string, unknown> = {
|
|
165
|
+
name,
|
|
166
|
+
email,
|
|
167
|
+
username,
|
|
168
|
+
password: DEFAULT_PASSWORD,
|
|
169
|
+
};
|
|
170
|
+
if (displayUsername !== undefined) {
|
|
171
|
+
body.display_username = displayUsername;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const req = new Request(`http://localhost${basePath}/sign-up/email`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
headers: { "Content-Type": "application/json" },
|
|
177
|
+
body: JSON.stringify(body),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const response = await Sonamu.auth.handler(req);
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
const responseData = (await response.json()) as Record<string, unknown>;
|
|
184
|
+
const code = typeof responseData.code === "string" ? responseData.code : undefined;
|
|
185
|
+
if (code === "USER_ALREADY_EXISTS") {
|
|
186
|
+
console.log(chalk.yellow(` ⚠️ ${email} 이미 존재 - 건너뜁니다.`));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
console.log(chalk.red(` ❌ ${email} 생성 실패: ${JSON.stringify(responseData)}`));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
createdCredentials.push({ email, password: DEFAULT_PASSWORD });
|
|
194
|
+
console.log(chalk.green(` ✅ ${email} 생성 완료`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// email_verified = true 직접 업데이트 (dev 편의)
|
|
198
|
+
if (createdCredentials.length > 0) {
|
|
199
|
+
const emails = createdCredentials.map((c) => c.email);
|
|
200
|
+
await DB.getDB("w")("users").whereIn("email", emails).update({ email_verified: true });
|
|
201
|
+
console.log(chalk.green(`\nemail_verified = true 업데이트 완료`));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (useLLM) {
|
|
205
|
+
const stats = generator.getLLMCacheStats();
|
|
206
|
+
console.log(chalk.cyan(`[LLM Cache] 캐시 크기: ${stats.size}`));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
console.log(
|
|
210
|
+
chalk.green(`\n✅ ${createdCredentials.length}명의 로그인 가능한 사용자 생성 완료`),
|
|
211
|
+
);
|
|
212
|
+
if (createdCredentials.length > 0) {
|
|
213
|
+
console.log("\n생성된 계정 목록:");
|
|
214
|
+
console.table(createdCredentials);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// userMode === "dummy": 기존 generateBatch() 흐름 계속
|
|
220
|
+
}
|
|
221
|
+
|
|
79
222
|
let saveTarget = options["save-to"] || "db";
|
|
80
223
|
if (!options["save-to"]) {
|
|
81
224
|
const result = await prompts({
|
|
@@ -15,7 +15,7 @@ export type RowWithId<Id extends string> = {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Batch update rows in a table. Technically its a patch since it only updates the specified columns. Any omitted columns will not be affected
|
|
18
|
-
* @param
|
|
18
|
+
* @param knex
|
|
19
19
|
* @param tableName
|
|
20
20
|
* @param ids
|
|
21
21
|
* @param rows
|
|
@@ -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
|
|
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(
|
|
451
|
+
const fkValues = [...new Set(table.rows.map((row) => row[fkCol]).filter(Boolean))];
|
|
452
452
|
return { column: fkCol, values: fkValues };
|
|
453
453
|
});
|
|
454
454
|
|
|
@@ -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]
|
|
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
|
}
|
|
@@ -267,9 +267,16 @@ class EntityManagerClass {
|
|
|
267
267
|
* @returns
|
|
268
268
|
*/
|
|
269
269
|
getEntityIdFromPath(filePath: AbsolutePath): string {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
const fileName = path.basename(filePath);
|
|
271
|
+
const supportedSuffixes = [".model.ts", ".model.js", ".entity.json", ".frame.ts", ".frame.js"];
|
|
272
|
+
const matchedSuffix = supportedSuffixes.find((suffix) => fileName.endsWith(suffix));
|
|
273
|
+
|
|
274
|
+
assert(matchedSuffix, `지원하지 않는 entity 경로입니다: ${filePath}`);
|
|
275
|
+
|
|
276
|
+
const entityBaseName = fileName.slice(0, -matchedSuffix.length);
|
|
277
|
+
assert(entityBaseName.length > 0, `EntityId를 계산할 수 없는 경로입니다: ${filePath}`);
|
|
278
|
+
|
|
279
|
+
return inflection.camelize(entityBaseName.replace(/-/g, "_"));
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
private async registerNonEntityTypeModulePaths(): Promise<void> {
|
|
@@ -871,12 +871,7 @@ function genNormalColumnDefinition(column: MigrationColumn): string {
|
|
|
871
871
|
chains.push(`jsonb('${column.name}')`);
|
|
872
872
|
} else {
|
|
873
873
|
// type, length
|
|
874
|
-
|
|
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
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* 최초 1회 생성되며, 이후에는 덮어쓰지 않습니다.
|
|
4
4
|
* 필요시 직접 수정할 수 있습니다.
|
|
5
5
|
*/
|
|
6
|
+
|
|
6
7
|
/* oxlint-disable react-hooks/exhaustive-deps */ // shared
|
|
7
|
-
/* oxlint-disable @typescript-eslint/no-explicit-any */ // shared
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
10
|
fetch
|
|
@@ -12,12 +12,12 @@
|
|
|
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 {
|
|
18
|
+
import { type InfiniteData } from "@tanstack/react-query";
|
|
19
|
+
import { getCurrentLocale } from "@/i18n/sd.generated";
|
|
19
20
|
import { ExpoEventSource as EventSource } from "@falcondev-oss/expo-event-source-polyfill";
|
|
20
|
-
import { getCurrentLocale } from "../i18n/sd.generated"
|
|
21
21
|
|
|
22
22
|
// AbortSignal.timeout polyfill for React Native
|
|
23
23
|
if (typeof AbortSignal !== "undefined" && !AbortSignal.timeout) {
|
|
@@ -511,7 +511,7 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
try {
|
|
514
|
-
const data = JSON.parse(event.data as string);
|
|
514
|
+
const data = JSON.parse(event.data as string, dateReviver);
|
|
515
515
|
handler(data);
|
|
516
516
|
} catch (error) {
|
|
517
517
|
console.error(`Failed to parse SSE data for event ${eventType}:`, error);
|
|
@@ -532,7 +532,7 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
532
532
|
}
|
|
533
533
|
|
|
534
534
|
try {
|
|
535
|
-
const data = JSON.parse(event.data as string);
|
|
535
|
+
const data = JSON.parse(event.data as string, dateReviver);
|
|
536
536
|
// 'message' 핸들러가 있으면 호출
|
|
537
537
|
const messageHandler = handlersRef.current["message" as keyof T];
|
|
538
538
|
if (messageHandler) {
|
|
@@ -585,3 +585,57 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
585
585
|
Dictionary Helper
|
|
586
586
|
*/
|
|
587
587
|
$[[dictUtils]]
|
|
588
|
+
/*
|
|
589
|
+
Query helpers
|
|
590
|
+
*/
|
|
591
|
+
type InfinitePage<TRow> = { rows: TRow[]; total: number };
|
|
592
|
+
type DedupedInfiniteData<TRow> = InfiniteData<InfinitePage<TRow>> & {
|
|
593
|
+
rows: TRow[];
|
|
594
|
+
total: number;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// useInfiniteQuery의 select에 꽂아 pages/pageParams 원본은 유지하면서
|
|
598
|
+
// 평탄화된 rows와 첫 페이지의 total을 data에 함께 노출합니다.
|
|
599
|
+
// 각 row가 id를 갖는 경우 id 기준으로 중복 제거합니다. id가 없으면 그대로 유지합니다.
|
|
600
|
+
export function dedupeAndFlatten<TRow extends { id?: unknown }>(
|
|
601
|
+
data: InfiniteData<InfinitePage<TRow>>,
|
|
602
|
+
): DedupedInfiniteData<TRow> {
|
|
603
|
+
const seen = new Set<unknown>();
|
|
604
|
+
const rows: TRow[] = [];
|
|
605
|
+
for (const page of data.pages) {
|
|
606
|
+
for (const row of page?.rows ?? []) {
|
|
607
|
+
const id = row?.id;
|
|
608
|
+
if (id !== null) {
|
|
609
|
+
if (seen.has(id)) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
seen.add(id);
|
|
613
|
+
}
|
|
614
|
+
rows.push(row);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const total = data.pages[0]?.total ?? 0;
|
|
618
|
+
return {
|
|
619
|
+
pages: data.pages,
|
|
620
|
+
pageParams: data.pageParams,
|
|
621
|
+
rows,
|
|
622
|
+
total,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// TanStack Query 결과에 수동 refresh 진입점과 새로고침 중 상태를 덧붙여 줍니다.
|
|
627
|
+
// isRefreshing은 query.isFetching과 독립적으로 이 함수 호출로 발생한 새로고침에 한정됩니다.
|
|
628
|
+
export function useRefreshable<T extends { refetch: () => Promise<unknown> }>(
|
|
629
|
+
query: T,
|
|
630
|
+
): T & { refresh: () => Promise<void>; isRefreshing: boolean } {
|
|
631
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
632
|
+
const refresh = useCallback(async () => {
|
|
633
|
+
setIsRefreshing(true);
|
|
634
|
+
try {
|
|
635
|
+
await query.refetch();
|
|
636
|
+
} finally {
|
|
637
|
+
setIsRefreshing(false);
|
|
638
|
+
}
|
|
639
|
+
}, [query]);
|
|
640
|
+
return { ...query, refresh, isRefreshing };
|
|
641
|
+
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* 최초 1회 생성되며, 이후에는 덮어쓰지 않습니다.
|
|
4
4
|
* 필요시 직접 수정할 수 있습니다.
|
|
5
5
|
*/
|
|
6
|
+
|
|
6
7
|
/* oxlint-disable react-hooks/exhaustive-deps */ // shared
|
|
7
|
-
/* oxlint-disable @typescript-eslint/no-explicit-any */ // shared
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
10
|
fetch
|
|
@@ -13,9 +13,10 @@ import type { AxiosRequestConfig } from "axios";
|
|
|
13
13
|
import axios from "axios";
|
|
14
14
|
import { EventSource } from "eventsource";
|
|
15
15
|
import qs from "qs";
|
|
16
|
-
import { useEffect, useRef, useState } from "react";
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
17
|
import { type core, z } from "zod";
|
|
18
|
-
import {
|
|
18
|
+
import { type InfiniteData } from "@tanstack/react-query";
|
|
19
|
+
import { getCurrentLocale } from "@/i18n/sd.generated";
|
|
19
20
|
|
|
20
21
|
// ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver
|
|
21
22
|
export function dateReviver(_key: string, value: any): any {
|
|
@@ -480,7 +481,7 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
480
481
|
}
|
|
481
482
|
|
|
482
483
|
try {
|
|
483
|
-
const data = JSON.parse(event.data);
|
|
484
|
+
const data = JSON.parse(event.data, dateReviver);
|
|
484
485
|
handler(data);
|
|
485
486
|
} catch (error) {
|
|
486
487
|
console.error(`Failed to parse SSE data for event ${eventType}:`, error);
|
|
@@ -501,7 +502,7 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
501
502
|
}
|
|
502
503
|
|
|
503
504
|
try {
|
|
504
|
-
const data = JSON.parse(event.data);
|
|
505
|
+
const data = JSON.parse(event.data, dateReviver);
|
|
505
506
|
// 'message' 핸들러가 있으면 호출
|
|
506
507
|
const messageHandler = handlersRef.current["message" as keyof T];
|
|
507
508
|
if (messageHandler) {
|
|
@@ -554,3 +555,57 @@ export function useSSEStream<T extends Record<string, any>>(
|
|
|
554
555
|
Dictionary Helper
|
|
555
556
|
*/
|
|
556
557
|
$[[dictUtils]]
|
|
558
|
+
/*
|
|
559
|
+
Query helpers
|
|
560
|
+
*/
|
|
561
|
+
type InfinitePage<TRow> = { rows: TRow[]; total: number };
|
|
562
|
+
type DedupedInfiniteData<TRow> = InfiniteData<InfinitePage<TRow>> & {
|
|
563
|
+
rows: TRow[];
|
|
564
|
+
total: number;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// useInfiniteQuery의 select에 꽂아 pages/pageParams 원본은 유지하면서
|
|
568
|
+
// 평탄화된 rows와 첫 페이지의 total을 data에 함께 노출합니다.
|
|
569
|
+
// 각 row가 id를 갖는 경우 id 기준으로 중복 제거합니다. id가 없으면 그대로 유지합니다.
|
|
570
|
+
export function dedupeAndFlatten<TRow extends { id?: unknown }>(
|
|
571
|
+
data: InfiniteData<InfinitePage<TRow>>,
|
|
572
|
+
): DedupedInfiniteData<TRow> {
|
|
573
|
+
const seen = new Set<unknown>();
|
|
574
|
+
const rows: TRow[] = [];
|
|
575
|
+
for (const page of data.pages) {
|
|
576
|
+
for (const row of page?.rows ?? []) {
|
|
577
|
+
const id = row?.id;
|
|
578
|
+
if (id !== null) {
|
|
579
|
+
if (seen.has(id)) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
seen.add(id);
|
|
583
|
+
}
|
|
584
|
+
rows.push(row);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const total = data.pages[0]?.total ?? 0;
|
|
588
|
+
return {
|
|
589
|
+
pages: data.pages,
|
|
590
|
+
pageParams: data.pageParams,
|
|
591
|
+
rows,
|
|
592
|
+
total,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// TanStack Query 결과에 수동 refresh 진입점과 새로고침 중 상태를 덧붙여 줍니다.
|
|
597
|
+
// isRefreshing은 query.isFetching과 독립적으로 이 함수 호출로 발생한 새로고침에 한정됩니다.
|
|
598
|
+
export function useRefreshable<T extends { refetch: () => Promise<unknown> }>(
|
|
599
|
+
query: T,
|
|
600
|
+
): T & { refresh: () => Promise<void>; isRefreshing: boolean } {
|
|
601
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
602
|
+
const refresh = useCallback(async () => {
|
|
603
|
+
setIsRefreshing(true);
|
|
604
|
+
try {
|
|
605
|
+
await query.refetch();
|
|
606
|
+
} finally {
|
|
607
|
+
setIsRefreshing(false);
|
|
608
|
+
}
|
|
609
|
+
}, [query]);
|
|
610
|
+
return { ...query, refresh, isRefreshing };
|
|
611
|
+
}
|
package/src/syncer/syncer.ts
CHANGED
|
@@ -5,7 +5,6 @@ import path from "path";
|
|
|
5
5
|
|
|
6
6
|
import { hot } from "@sonamu-kit/hmr-hook";
|
|
7
7
|
import chalk from "chalk";
|
|
8
|
-
import inflection from "inflection";
|
|
9
8
|
import { minimatch } from "minimatch";
|
|
10
9
|
import { group, unique } from "radashi";
|
|
11
10
|
import { type z } from "zod";
|
|
@@ -452,22 +451,13 @@ export class Syncer {
|
|
|
452
451
|
const params: {
|
|
453
452
|
namesRecord: EntityNamesRecord;
|
|
454
453
|
}[] = mergedGroup.map((modelPath) => {
|
|
455
|
-
if (modelPath.endsWith(".model.ts")) {
|
|
454
|
+
if (modelPath.endsWith(".model.ts") || modelPath.endsWith(".frame.ts")) {
|
|
456
455
|
const entityId = EntityManager.getEntityIdFromPath(modelPath);
|
|
457
456
|
assert(entityId);
|
|
458
457
|
return {
|
|
459
458
|
namesRecord: EntityManager.getNamesFromId(entityId),
|
|
460
459
|
};
|
|
461
460
|
}
|
|
462
|
-
if (modelPath.endsWith(".frame.ts")) {
|
|
463
|
-
const [, frameName] = modelPath.match(/.+\/(.+)\.frame\.ts$/) ?? [];
|
|
464
|
-
assert(frameName);
|
|
465
|
-
// frameName을 PascalCase로 변환 (dashboard -> Dashboard)
|
|
466
|
-
const frameId = inflection.camelize(frameName);
|
|
467
|
-
return {
|
|
468
|
-
namesRecord: EntityManager.getNamesFromId(frameId),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
461
|
throw new Error("not reachable");
|
|
472
462
|
});
|
|
473
463
|
|
|
@@ -75,7 +75,7 @@ export async function render(url: string, preloadedData: PreloadedData[] = []) {
|
|
|
75
75
|
...this.getTargetAndPath(),
|
|
76
76
|
body,
|
|
77
77
|
importKeys: [],
|
|
78
|
-
customHeaders: ["/**", " * @generated", " * 직접 수정하지 마세요.", " */"],
|
|
78
|
+
customHeaders: ["/**", " * @generated", " * 직접 수정하지 마세요.", " */", ""],
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -106,6 +106,7 @@ ${functions.join("\n\n")}
|
|
|
106
106
|
" * @generated",
|
|
107
107
|
" * 직접 수정하지 마세요.",
|
|
108
108
|
" */",
|
|
109
|
+
"",
|
|
109
110
|
"/* oxlint-disable */",
|
|
110
111
|
"",
|
|
111
112
|
`import type { SSRQuery } from 'sonamu/ssr';`,
|
|
@@ -116,7 +117,15 @@ ${functions.join("\n\n")}
|
|
|
116
117
|
`}`,
|
|
117
118
|
"",
|
|
118
119
|
]
|
|
119
|
-
: [
|
|
120
|
+
: [
|
|
121
|
+
"/**",
|
|
122
|
+
" * @generated",
|
|
123
|
+
" * 직접 수정하지 마세요.",
|
|
124
|
+
" */",
|
|
125
|
+
"",
|
|
126
|
+
"/* oxlint-disable */",
|
|
127
|
+
"",
|
|
128
|
+
],
|
|
120
129
|
};
|
|
121
130
|
}
|
|
122
131
|
}
|
|
@@ -250,7 +250,7 @@ SD.enumLabels = (enumName: string): Record<string, LocalizedString> => {
|
|
|
250
250
|
...this.getTargetAndPath(undefined, target),
|
|
251
251
|
body,
|
|
252
252
|
importKeys: [],
|
|
253
|
-
customHeaders: ["/**", " * @generated", " * 직접 수정하지 마세요.", " */"],
|
|
253
|
+
customHeaders: ["/**", " * @generated", " * 직접 수정하지 마세요.", " */", ""],
|
|
254
254
|
};
|
|
255
255
|
}
|
|
256
256
|
|