sonamu 0.9.4 → 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.
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>{{projectName}}: Sonamu UI</title>
8
- <script type="module" crossorigin src="/sonamu-ui/assets/index-C5KUjXm0.js"></script>
9
- <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-Dr8pRJC_.css">
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-DzZ7vBk4.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-D4rYm-Xz.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "framework",
@@ -119,7 +119,7 @@
119
119
  "node-cron": "^4.2.1",
120
120
  "node-sql-parser": "^5.2.0",
121
121
  "oxfmt": "^0.43.0",
122
- "oxlint": "^1.58.0",
122
+ "oxlint": "^1.61.0",
123
123
  "pg": "^8.16.3",
124
124
  "prompts": "^2.4.2",
125
125
  "qs": "^6.14.1",
@@ -142,7 +142,7 @@
142
142
  "@types/qs": "^6.14.0",
143
143
  "nodemon": "^3.1.10",
144
144
  "tsdown": "^0.12.5",
145
- "typescript": "^6.0.0"
145
+ "typescript": "^6.0.3"
146
146
  },
147
147
  "peerDependencies": {
148
148
  "@ai-sdk/anthropic": "^3.0.0",
@@ -158,7 +158,7 @@
158
158
  "ioredis": "^5.8.2",
159
159
  "knex": "^3.2.9",
160
160
  "pgvector": "^0.2.1",
161
- "typescript": "^6.0.0",
161
+ "typescript": "^6.0.3",
162
162
  "voyageai": "^0.0.8",
163
163
  "zod": "^4.3.6"
164
164
  },
@@ -23,7 +23,7 @@ export function handleFetchError({
23
23
  ) {
24
24
  const cause = (error as { cause?: Error }).cause;
25
25
 
26
- if (cause != null) {
26
+ if (cause) {
27
27
  return new APICallError({
28
28
  message: `Cannot connect to API: ${cause.message}`,
29
29
  cause,
@@ -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
  }
@@ -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
@@ -12,9 +12,10 @@
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 { type InfiniteData } from "@tanstack/react-query";
18
19
  import { getCurrentLocale } from "@/i18n/sd.generated";
19
20
  import { ExpoEventSource as EventSource } from "@falcondev-oss/expo-event-source-polyfill";
20
21
 
@@ -510,7 +511,7 @@ export function useSSEStream<T extends Record<string, any>>(
510
511
  }
511
512
 
512
513
  try {
513
- const data = JSON.parse(event.data as string);
514
+ const data = JSON.parse(event.data as string, dateReviver);
514
515
  handler(data);
515
516
  } catch (error) {
516
517
  console.error(`Failed to parse SSE data for event ${eventType}:`, error);
@@ -531,7 +532,7 @@ export function useSSEStream<T extends Record<string, any>>(
531
532
  }
532
533
 
533
534
  try {
534
- const data = JSON.parse(event.data as string);
535
+ const data = JSON.parse(event.data as string, dateReviver);
535
536
  // 'message' 핸들러가 있으면 호출
536
537
  const messageHandler = handlersRef.current["message" as keyof T];
537
538
  if (messageHandler) {
@@ -584,3 +585,57 @@ export function useSSEStream<T extends Record<string, any>>(
584
585
  Dictionary Helper
585
586
  */
586
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
+ }
@@ -13,8 +13,9 @@ 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 { type InfiniteData } from "@tanstack/react-query";
18
19
  import { getCurrentLocale } from "@/i18n/sd.generated";
19
20
 
20
21
  // ISO 8601 및 타임존 포맷의 날짜 문자열을 Date 객체로 변환하는 reviver
@@ -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
+ }
@@ -4,6 +4,7 @@ import { type CacheManager } from "../cache/types";
4
4
  import { type Entity } from "../entity/entity";
5
5
  import { type EntityManager } from "../entity/entity-manager";
6
6
  import { isBelongsToOneRelationProp, isOneToOneRelationProp, isRelationProp } from "../types/types";
7
+ import { nonNullable } from "../utils/utils";
7
8
 
8
9
  export type DataExplorerStrategy = "sample" | "ids" | "query" | "file" | "recent" | "random";
9
10
 
@@ -323,7 +324,7 @@ export class DataExplorer {
323
324
  }
324
325
 
325
326
  const entity = this.entityManager.get(entityName);
326
- const recordIds = records.map((r) => r.id).filter((id) => id != null);
327
+ const recordIds = records.map((r) => r.id).filter(nonNullable);
327
328
 
328
329
  // 1. Forward references: 이 entity가 참조하는 다른 entity
329
330
  const forwardRelationProps = entity.props.filter(
@@ -346,7 +347,7 @@ export class DataExplorer {
346
347
  const foreignKeyName = `${prop.name}_id`;
347
348
  const referencedIds = records
348
349
  .map((record) => record[foreignKeyName])
349
- .filter((id) => id != null) as number[];
350
+ .filter(Boolean) as number[];
350
351
 
351
352
  if (referencedIds.length === 0) {
352
353
  continue;
package/src/ui/api.ts CHANGED
@@ -256,8 +256,17 @@ export async function sonamuUIApiPlugin(fastify: FastifyInstance) {
256
256
  const entity = EntityManager.get(entityId);
257
257
  const subsetRows = entity.getSubsetRows();
258
258
 
259
+ // zod 인스턴스를 spread하면 JSON.stringify가 reference를 인라인으로 풀어내며 응답이 수백 MB까지 부풀어 V8 string limit를 초과한다.
260
+ const {
261
+ types: _types,
262
+ enums: _enums,
263
+ enumCones: _enumCones,
264
+ subsetCones: _subsetCones,
265
+ ...rest
266
+ } = entity;
267
+
259
268
  return {
260
- ...entity,
269
+ ...rest,
261
270
  flattenSubsetRows: flattenSubsetRows(subsetRows),
262
271
  };
263
272
  }),