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.
Files changed (99) hide show
  1. package/dist/ai/providers/rtzr/utils.js +2 -2
  2. package/dist/api/config.d.ts +0 -8
  3. package/dist/api/config.d.ts.map +1 -1
  4. package/dist/api/config.js +1 -1
  5. package/dist/api/sonamu.d.ts +0 -1
  6. package/dist/api/sonamu.d.ts.map +1 -1
  7. package/dist/api/sonamu.js +2 -41
  8. package/dist/auth/audit-log/builders.d.ts +216 -0
  9. package/dist/auth/audit-log/builders.d.ts.map +1 -0
  10. package/dist/auth/audit-log/builders.js +307 -0
  11. package/dist/auth/audit-log/events.d.ts +143 -0
  12. package/dist/auth/audit-log/events.d.ts.map +1 -0
  13. package/dist/auth/audit-log/events.js +74 -0
  14. package/dist/auth/audit-log/plugin.d.ts +11 -0
  15. package/dist/auth/audit-log/plugin.d.ts.map +1 -0
  16. package/dist/auth/audit-log/plugin.js +427 -0
  17. package/dist/auth/audit-log-ingestor.d.ts +3 -3
  18. package/dist/auth/audit-log-ingestor.d.ts.map +1 -1
  19. package/dist/auth/audit-log-ingestor.js +44 -50
  20. package/dist/auth/index.d.ts +2 -0
  21. package/dist/auth/index.d.ts.map +1 -1
  22. package/dist/auth/index.js +4 -4
  23. package/dist/auth/plugins/entity-definitions/admin.d.ts +1 -1
  24. package/dist/auth/plugins/entity-definitions/admin.js +4 -4
  25. package/dist/auth/plugins/entity-definitions/audit-log.d.ts +2 -2
  26. package/dist/auth/plugins/entity-definitions/audit-log.js +3 -3
  27. package/dist/auth/plugins/wrappers/admin.d.ts +2 -2
  28. package/dist/auth/plugins/wrappers/sso.d.ts +1 -1
  29. package/dist/bin/fixture.d.ts.map +1 -1
  30. package/dist/bin/fixture.js +111 -1
  31. package/dist/database/_batch_update.d.ts +1 -1
  32. package/dist/database/_batch_update.js +2 -2
  33. package/dist/database/upsert-builder.js +4 -4
  34. package/dist/dict/sonamu-dictionary.js +2 -2
  35. package/dist/entity/entity-manager.d.ts +2 -2
  36. package/dist/entity/entity-manager.d.ts.map +1 -1
  37. package/dist/entity/entity-manager.js +14 -4
  38. package/dist/index.js +4 -3
  39. package/dist/migration/code-generation.d.ts.map +1 -1
  40. package/dist/migration/code-generation.js +2 -3
  41. package/dist/syncer/syncer.d.ts.map +1 -1
  42. package/dist/syncer/syncer.js +2 -9
  43. package/dist/template/implementations/entry-server.template.js +3 -2
  44. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  45. package/dist/template/implementations/generated.template.js +2 -1
  46. package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
  47. package/dist/template/implementations/generated_sso.template.js +2 -1
  48. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  49. package/dist/template/implementations/queries.template.js +3 -1
  50. package/dist/template/implementations/sd.template.js +3 -2
  51. package/dist/template/implementations/services.template.d.ts.map +1 -1
  52. package/dist/template/implementations/services.template.js +44 -7
  53. package/dist/template/zod-converter.d.ts.map +1 -1
  54. package/dist/template/zod-converter.js +2 -2
  55. package/dist/testing/data-explorer.d.ts.map +1 -1
  56. package/dist/testing/data-explorer.js +5 -3
  57. package/dist/types/types.d.ts +14 -14
  58. package/dist/ui/api.d.ts.map +1 -1
  59. package/dist/ui/api.js +3 -2
  60. package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
  61. package/dist/ui-web/assets/{index-DrTfl0Ts.js → index-DzZ7vBk4.js} +47 -47
  62. package/dist/ui-web/index.html +2 -2
  63. package/dist/utils/fs-utils.d.ts.map +1 -1
  64. package/dist/utils/fs-utils.js +4 -4
  65. package/package.json +4 -5
  66. package/src/ai/providers/rtzr/utils.ts +1 -1
  67. package/src/api/config.ts +0 -8
  68. package/src/api/sonamu.ts +1 -51
  69. package/src/auth/audit-log/builders.ts +791 -0
  70. package/src/auth/audit-log/events.ts +149 -0
  71. package/src/auth/audit-log/plugin.ts +913 -0
  72. package/src/auth/audit-log-ingestor.ts +3 -4
  73. package/src/auth/index.ts +2 -0
  74. package/src/auth/plugins/entity-definitions/admin.ts +3 -3
  75. package/src/auth/plugins/entity-definitions/audit-log.ts +2 -2
  76. package/src/bin/fixture.ts +143 -0
  77. package/src/database/_batch_update.ts +1 -1
  78. package/src/database/upsert-builder.ts +3 -3
  79. package/src/dict/sonamu-dictionary.ts +1 -1
  80. package/src/entity/entity-manager.ts +10 -3
  81. package/src/migration/code-generation.ts +1 -6
  82. package/src/shared/app.shared.ts.txt +60 -6
  83. package/src/shared/web.shared.ts.txt +60 -5
  84. package/src/syncer/syncer.ts +1 -11
  85. package/src/template/implementations/entry-server.template.ts +1 -1
  86. package/src/template/implementations/generated.template.ts +1 -0
  87. package/src/template/implementations/generated_sso.template.ts +1 -0
  88. package/src/template/implementations/queries.template.ts +10 -1
  89. package/src/template/implementations/sd.template.ts +1 -1
  90. package/src/template/implementations/services.template.ts +62 -6
  91. package/src/template/zod-converter.ts +2 -1
  92. package/src/testing/data-explorer.ts +3 -2
  93. package/src/ui/api.ts +10 -1
  94. package/src/utils/fs-utils.ts +6 -4
  95. package/dist/auth/audit-log-proxy-types.d.ts +0 -23
  96. package/dist/auth/audit-log-proxy-types.d.ts.map +0 -1
  97. package/dist/auth/audit-log-proxy-types.js +0 -1
  98. package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
  99. 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-proxy-types";
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
- * Better Auth dash() 이벤트를 audit_events 테이블에 적재합니다.
141
+ * sonamuAuditLog 플러그인이 구성한 AuditLogEvent를 audit_events 테이블에 적재합니다.
143
142
  * ON CONFLICT (dedupe_key) DO NOTHING으로 중복을 silent 무시합니다.
144
- * auth.auditLog: true 설정 시 sonamu 내부에서 자동으로 호출됩니다.
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: 차단 만료 시간 (Unix timestamp)
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: "bigInteger",
46
+ type: "date",
47
47
  nullable: true,
48
- desc: "차단 만료 (Unix timestamp)",
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.auditLog: true 활성화되며 audit_events 테이블을 생성합니다.
7
- * - Better Auth dash() 플러그인이 전송하는 이벤트를 1건씩 적재합니다.
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
@@ -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 db
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 == null)) {
334
+ if (!values.some((v) => v === null || v === undefined)) {
335
335
  conditions.push(values);
336
336
  }
337
337
  }
@@ -448,7 +448,7 @@ export class UpsertBuilder {
448
448
 
449
449
  // 현재 register된 레코드들의 FK 값들 추출
450
450
  const fkConditions = fkColumns.map((fkCol) => {
451
- const fkValues = [...new Set(table.rows.map((row) => row[fkCol]).filter((v) => v != null))];
451
+ const fkValues = [...new Set(table.rows.map((row) => row[fkCol]).filter(Boolean))];
452
452
  return { column: fkCol, values: fkValues };
453
453
  });
454
454
 
@@ -631,7 +631,7 @@ export class SonamuDictionary {
631
631
  const stats: Record<string, { total: number; filled: number; percent: number }> = {};
632
632
  const total = rows.length;
633
633
  for (const locale of locales) {
634
- const filled = rows.filter((row) => row[locale] != null && row[locale] !== "").length;
634
+ const filled = rows.filter((row) => !!row[locale]).length;
635
635
  const percent = total > 0 ? Math.round((filled / total) * 100) : 0;
636
636
  stats[locale] = { total, filled, percent };
637
637
  }
@@ -267,9 +267,16 @@ class EntityManagerClass {
267
267
  * @returns
268
268
  */
269
269
  getEntityIdFromPath(filePath: AbsolutePath): string {
270
- const matched = filePath.match(/application\/(.+)\//);
271
- assert(matched?.[1]);
272
- return inflection.camelize(matched[1].replace(/-/g, "_"));
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
- let extraType: string | undefined;
875
- chains.push(
876
- `${column.type}('${column.name}'${
877
- column.length ? `, ${column.length}` : ""
878
- }${extraType ? `, '${extraType}'` : ""})`,
879
- );
874
+ chains.push(`${column.type}('${column.name}'${column.length ? `, ${column.length}` : ""})`);
880
875
  }
881
876
 
882
877
  // nullable
@@ -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 { getCurrentLocale } from "~/i18n/sd.generated";
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 { getCurrentLocale } from "../i18n/sd.generated";
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
+ }
@@ -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
  }
@@ -130,6 +130,7 @@ export class Template__generated extends Template {
130
130
  " * @generated",
131
131
  " * 직접 수정하지 마세요.",
132
132
  " */",
133
+ "",
133
134
  "/* oxlint-disable */",
134
135
  "",
135
136
  `import { z } from 'zod';`,
@@ -152,6 +152,7 @@ export class Template__generated_sso extends Template {
152
152
  " * @generated",
153
153
  " * 직접 수정하지 마세요.",
154
154
  " */",
155
+ "",
155
156
  `import { ${sonamuImports} } from "sonamu";`,
156
157
  ];
157
158
  if (this.hasAuthConfig()) {
@@ -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
- : ["/**", " * @generated", " * 직접 수정하지 마세요.", " */", "/* oxlint-disable */", ""],
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