sonamu 0.9.4 → 0.9.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.
Files changed (171) hide show
  1. package/dist/ai/providers/rtzr/utils.js +2 -2
  2. package/dist/api/config.d.ts +13 -2
  3. package/dist/api/config.d.ts.map +1 -1
  4. package/dist/api/config.js +1 -1
  5. package/dist/api/context.d.ts +17 -7
  6. package/dist/api/context.d.ts.map +1 -1
  7. package/dist/api/context.js +1 -1
  8. package/dist/api/decorators.d.ts +18 -0
  9. package/dist/api/decorators.d.ts.map +1 -1
  10. package/dist/api/decorators.js +54 -3
  11. package/dist/api/index.js +8 -3
  12. package/dist/api/sonamu.d.ts +24 -9
  13. package/dist/api/sonamu.d.ts.map +1 -1
  14. package/dist/api/sonamu.js +365 -79
  15. package/dist/api/websocket-helpers.d.ts +24 -0
  16. package/dist/api/websocket-helpers.d.ts.map +1 -0
  17. package/dist/api/websocket-helpers.js +77 -0
  18. package/dist/bin/cli.js +12 -4
  19. package/dist/database/upsert-builder.js +4 -4
  20. package/dist/dict/sonamu-dictionary.js +6 -6
  21. package/dist/entity/entity-manager.js +1 -1
  22. package/dist/entity/entity.js +3 -3
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -4
  26. package/dist/migration/code-generation.d.ts.map +1 -1
  27. package/dist/migration/code-generation.js +8 -9
  28. package/dist/stream/index.d.ts +6 -0
  29. package/dist/stream/index.d.ts.map +1 -1
  30. package/dist/stream/index.js +13 -2
  31. package/dist/stream/ws-audience-resolver.d.ts +15 -0
  32. package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
  33. package/dist/stream/ws-audience-resolver.js +31 -0
  34. package/dist/stream/ws-audience.d.ts +28 -0
  35. package/dist/stream/ws-audience.d.ts.map +1 -0
  36. package/dist/stream/ws-audience.js +46 -0
  37. package/dist/stream/ws-cluster-bus.d.ts +23 -0
  38. package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
  39. package/dist/stream/ws-cluster-bus.js +18 -0
  40. package/dist/stream/ws-core.d.ts +15 -0
  41. package/dist/stream/ws-core.d.ts.map +1 -0
  42. package/dist/stream/ws-core.js +1 -0
  43. package/dist/stream/ws-delivery.d.ts +24 -0
  44. package/dist/stream/ws-delivery.d.ts.map +1 -0
  45. package/dist/stream/ws-delivery.js +103 -0
  46. package/dist/stream/ws-local-connection-store.d.ts +10 -0
  47. package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
  48. package/dist/stream/ws-local-connection-store.js +44 -0
  49. package/dist/stream/ws-presence-store.d.ts +61 -0
  50. package/dist/stream/ws-presence-store.d.ts.map +1 -0
  51. package/dist/stream/ws-presence-store.js +236 -0
  52. package/dist/stream/ws-registry.d.ts +42 -0
  53. package/dist/stream/ws-registry.d.ts.map +1 -0
  54. package/dist/stream/ws-registry.js +108 -0
  55. package/dist/stream/ws.d.ts +52 -0
  56. package/dist/stream/ws.d.ts.map +1 -0
  57. package/dist/stream/ws.js +397 -0
  58. package/dist/syncer/api-parser.d.ts.map +1 -1
  59. package/dist/syncer/api-parser.js +72 -2
  60. package/dist/syncer/checksum.d.ts.map +1 -1
  61. package/dist/syncer/checksum.js +13 -12
  62. package/dist/syncer/code-generator.d.ts.map +1 -1
  63. package/dist/syncer/code-generator.js +7 -4
  64. package/dist/syncer/event-batcher.d.ts +27 -0
  65. package/dist/syncer/event-batcher.d.ts.map +1 -0
  66. package/dist/syncer/event-batcher.js +69 -0
  67. package/dist/syncer/file-patterns.d.ts +48 -26
  68. package/dist/syncer/file-patterns.d.ts.map +1 -1
  69. package/dist/syncer/file-patterns.js +71 -23
  70. package/dist/syncer/file-tracking.d.ts +13 -0
  71. package/dist/syncer/file-tracking.d.ts.map +1 -0
  72. package/dist/syncer/file-tracking.js +33 -0
  73. package/dist/syncer/index.js +2 -2
  74. package/dist/syncer/module-loader.d.ts +2 -11
  75. package/dist/syncer/module-loader.d.ts.map +1 -1
  76. package/dist/syncer/module-loader.js +3 -3
  77. package/dist/syncer/syncer-actions.d.ts +39 -6
  78. package/dist/syncer/syncer-actions.d.ts.map +1 -1
  79. package/dist/syncer/syncer-actions.js +125 -10
  80. package/dist/syncer/syncer.d.ts +33 -19
  81. package/dist/syncer/syncer.d.ts.map +1 -1
  82. package/dist/syncer/syncer.js +168 -168
  83. package/dist/syncer/watcher.d.ts +8 -0
  84. package/dist/syncer/watcher.d.ts.map +1 -0
  85. package/dist/syncer/watcher.js +105 -0
  86. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  87. package/dist/tasks/workflow-manager.js +2 -1
  88. package/dist/template/implementations/services.template.d.ts.map +1 -1
  89. package/dist/template/implementations/services.template.js +36 -1
  90. package/dist/testing/bootstrap.d.ts.map +1 -1
  91. package/dist/testing/bootstrap.js +8 -1
  92. package/dist/testing/data-explorer.d.ts.map +1 -1
  93. package/dist/testing/data-explorer.js +5 -3
  94. package/dist/testing/fixture-manager.js +1 -1
  95. package/dist/types/types.d.ts +2 -1
  96. package/dist/types/types.d.ts.map +1 -1
  97. package/dist/types/types.js +2 -2
  98. package/dist/ui/api.d.ts.map +1 -1
  99. package/dist/ui/api.js +4 -3
  100. package/dist/ui/cdd-service.js +1 -1
  101. package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
  102. package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
  103. package/dist/ui-web/index.html +2 -2
  104. package/dist/utils/async-utils.d.ts +27 -3
  105. package/dist/utils/async-utils.d.ts.map +1 -1
  106. package/dist/utils/async-utils.js +56 -6
  107. package/dist/utils/formatter.d.ts +7 -1
  108. package/dist/utils/formatter.d.ts.map +1 -1
  109. package/dist/utils/formatter.js +95 -60
  110. package/dist/utils/fs-utils.d.ts +2 -0
  111. package/dist/utils/fs-utils.d.ts.map +1 -1
  112. package/dist/utils/fs-utils.js +10 -2
  113. package/dist/utils/process-utils.d.ts +6 -0
  114. package/dist/utils/process-utils.d.ts.map +1 -1
  115. package/dist/utils/process-utils.js +16 -3
  116. package/dist/utils/utils.d.ts +1 -0
  117. package/dist/utils/utils.d.ts.map +1 -1
  118. package/dist/utils/utils.js +2 -2
  119. package/package.json +7 -5
  120. package/src/ai/providers/rtzr/utils.ts +1 -1
  121. package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
  122. package/src/api/__tests__/websocket-context.types.test.ts +58 -0
  123. package/src/api/config.ts +28 -2
  124. package/src/api/context.ts +21 -7
  125. package/src/api/decorators.ts +101 -1
  126. package/src/api/sonamu.ts +529 -127
  127. package/src/api/websocket-helpers.ts +122 -0
  128. package/src/bin/cli.ts +10 -2
  129. package/src/database/upsert-builder.ts +3 -3
  130. package/src/dict/sonamu-dictionary.ts +3 -3
  131. package/src/entity/entity.ts +1 -1
  132. package/src/index.ts +6 -0
  133. package/src/migration/code-generation.ts +6 -11
  134. package/src/shared/app.shared.ts.txt +312 -4
  135. package/src/shared/web.shared.ts.txt +340 -4
  136. package/src/stream/__tests__/ws-contracts.test.ts +381 -0
  137. package/src/stream/__tests__/ws.test.ts +449 -0
  138. package/src/stream/index.ts +6 -0
  139. package/src/stream/ws-audience-resolver.ts +35 -0
  140. package/src/stream/ws-audience.ts +62 -0
  141. package/src/stream/ws-cluster-bus.ts +32 -0
  142. package/src/stream/ws-core.ts +16 -0
  143. package/src/stream/ws-delivery.ts +138 -0
  144. package/src/stream/ws-local-connection-store.ts +44 -0
  145. package/src/stream/ws-presence-store.ts +326 -0
  146. package/src/stream/ws-registry.ts +138 -0
  147. package/src/stream/ws.ts +591 -0
  148. package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
  149. package/src/syncer/api-parser.ts +112 -1
  150. package/src/syncer/checksum.ts +23 -29
  151. package/src/syncer/code-generator.ts +4 -1
  152. package/src/syncer/event-batcher.ts +72 -0
  153. package/src/syncer/file-patterns.ts +98 -30
  154. package/src/syncer/file-tracking.ts +27 -0
  155. package/src/syncer/module-loader.ts +5 -12
  156. package/src/syncer/syncer-actions.ts +179 -17
  157. package/src/syncer/syncer.ts +250 -287
  158. package/src/syncer/watcher.ts +128 -0
  159. package/src/tasks/workflow-manager.ts +1 -0
  160. package/src/template/__tests__/services.template.websocket.test.ts +79 -0
  161. package/src/template/implementations/services.template.ts +69 -0
  162. package/src/testing/bootstrap.ts +8 -1
  163. package/src/testing/data-explorer.ts +3 -2
  164. package/src/types/types.ts +20 -2
  165. package/src/ui/api.ts +10 -1
  166. package/src/utils/async-utils.ts +71 -4
  167. package/src/utils/formatter.ts +114 -75
  168. package/src/utils/fs-utils.ts +9 -0
  169. package/src/utils/process-utils.ts +17 -0
  170. package/src/utils/utils.ts +1 -1
  171. package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
@@ -0,0 +1,128 @@
1
+ import { strict as assert } from "assert";
2
+ import { type Stats } from "node:fs";
3
+ import path from "path";
4
+
5
+ import chalk from "chalk";
6
+ import chokidar, { type FSWatcher } from "chokidar";
7
+ import { minimatch } from "minimatch";
8
+
9
+ import { Sonamu } from "../api";
10
+ import { type AbsolutePath } from "../utils/path-utils";
11
+ import { createFileEventBatcher } from "./event-batcher";
12
+ import { getChecksumPatternGroupInAbsolutePath } from "./file-patterns";
13
+ import { isLastChangedByMe } from "./file-tracking";
14
+
15
+ /**
16
+ * Watcher를 설정합니다.
17
+ * 이 친구는 진짜로 syncer가 받아야 할 변경 이벤트들만 추려서 batch로 전달해줍니다.
18
+ */
19
+ export async function setupWatcher(
20
+ onFileEvents: (fileEvents: Map<AbsolutePath, "change" | "add">) => Promise<void>,
21
+ ): Promise<FSWatcher> {
22
+ // api 본인뿐 아니라 sync target들의 src도 봅니다.
23
+ // target 산출물(sonamu.generated, services.generated, i18n copy 등)이 외부에서
24
+ // 변경되는 경우를 drift로 잡아 워닝을 띄우기 위함입니다.
25
+ const watcher = chokidar.watch(apiAndTargetsSrcPaths(), {
26
+ ignored: ignoreIfExtensionIsNotOneOf(".ts", ".json", ".http"),
27
+ persistent: true,
28
+ ignoreInitial: true,
29
+ });
30
+
31
+ // 100ms 안에 들어온 변경들을 한 batch로 모아 한 사이클로 처리합니다.
32
+ const pushFileEvent = createFileEventBatcher<"change" | "add">({
33
+ delayMs: 100,
34
+ onFlush: async (fileEvents) => {
35
+ const realChanges = new Map<AbsolutePath, "change" | "add">();
36
+ for (const [p, e] of fileEvents) {
37
+ // self-write echo는 flush 시점에 거릅니다.
38
+ // watcher.on에서 즉시 거르면 너무 이릅니다.
39
+ // 여기에서는 파일이 디스크에 쓰이고 나서 약간의 딜레이가 있기 때문에
40
+ // "디스크에 썼지만 아직 trackWritten이 완료되기 전 상태" 같은 문제가 사라집니다.
41
+ if (!(await isLastChangedByMe(p))) {
42
+ realChanges.set(p, e);
43
+ }
44
+ }
45
+ if (realChanges.size > 0) {
46
+ await onFileEvents(realChanges);
47
+ }
48
+ },
49
+ });
50
+
51
+ watcher.on("all", (event: string, filePath: string) => {
52
+ const absolutePath = filePath as AbsolutePath;
53
+ assert(
54
+ absolutePath.startsWith(Sonamu.appRootPath),
55
+ "File path is not within the app root path",
56
+ );
57
+
58
+ if (!isWantedEvent(event)) {
59
+ return;
60
+ }
61
+
62
+ if (isConfigChange(absolutePath)) {
63
+ triggerSelfRestart(event, filePath);
64
+ return;
65
+ }
66
+
67
+ if (isOutOfScope(absolutePath)) {
68
+ return;
69
+ }
70
+
71
+ // 여기까지 왔다면 syncer가 진짜로 처리해야 할 변경사항이라고 보면 됩니다.
72
+ // 이 호출은 이벤트를 batcher에 쌓습니다.
73
+ // 이후 때가 되면 이렇게 쌓인 이벤트를 들고 onFileEvents 콜백이 호출됩니다.
74
+ pushFileEvent(absolutePath, event);
75
+ });
76
+
77
+ return watcher;
78
+ }
79
+
80
+ function apiAndTargetsSrcPaths() {
81
+ return [
82
+ path.join(Sonamu.apiRootPath, "src"),
83
+ ...Sonamu.config.sync.targets.map((t) => path.join(Sonamu.appRootPath, t, "src")),
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * chokidar의 `ignored` 옵션에 박는 헬퍼.
89
+ * 주어진 확장자가 *아닌* 파일은 무시합니다 — 즉 주어진 확장자만 watch 대상.
90
+ *
91
+ * chokidar `ignored`는 `true`를 반환하면 해당 경로를 무시합니다.
92
+ * 따라서 "이 확장자만 허용"을 표현하려면 *다른 확장자에 대해 true*를 반환해야 합니다.
93
+ */
94
+ function ignoreIfExtensionIsNotOneOf(...allowedExtensions: `.${string}`[]) {
95
+ const isAllowed = (ext: string) => (allowedExtensions as string[]).includes(ext);
96
+
97
+ return (p: string, stats?: Stats) => !!stats?.isFile() && !isAllowed(path.extname(p));
98
+ }
99
+
100
+ function isWantedEvent(event: string): event is "change" | "add" {
101
+ return event === "change" || event === "add";
102
+ }
103
+
104
+ function isConfigChange(filePath: AbsolutePath): boolean {
105
+ return filePath === path.join(Sonamu.apiRootPath, "src", "sonamu.config.ts");
106
+ }
107
+
108
+ function triggerSelfRestart(event: string, filePath: string): void {
109
+ const relativePath = filePath.replace(Sonamu.apiRootPath, "api");
110
+ console.log(chalk.bold(`Detected(${event}): ${chalk.blue(relativePath)} - Restarting...`));
111
+ process.kill(process.pid, "SIGUSR2");
112
+ }
113
+
114
+ /**
115
+ * 스코프 정의:
116
+ * - api/src 안의 모든 변경은 스코프 안 (HMR 대상이 될 수 있으므로)
117
+ * - api/src 밖이라면 checksumPatternGroup 패턴에 매칭되는 경로만 스코프 안
118
+ *
119
+ * 그 외(예: web/src/App.tsx 같은 target의 비추적 파일)는 스코프 밖이라 무시합니다.
120
+ */
121
+ function isOutOfScope(filePath: AbsolutePath): boolean {
122
+ const apiSrc = path.join(Sonamu.apiRootPath, "src");
123
+ if (filePath.startsWith(apiSrc)) {
124
+ return false;
125
+ }
126
+ const checkPatternGroup = getChecksumPatternGroupInAbsolutePath();
127
+ return !Object.values(checkPatternGroup).some((pattern) => minimatch(filePath, pattern));
128
+ }
@@ -288,6 +288,7 @@ export class WorkflowManager {
288
288
  }>,
289
289
  ) => {
290
290
  const baseContext = {
291
+ transport: "http" as const,
291
292
  request: null,
292
293
  reply: null,
293
294
  headers: {},
@@ -0,0 +1,79 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import { Sonamu } from "../../api";
5
+ import { type ExtendedApi } from "../../api/decorators";
6
+ import { EntityManager } from "../../entity/entity-manager";
7
+ import { Template__services } from "../implementations/services.template";
8
+
9
+ describe("Template__services websocket event refs", () => {
10
+ let originalConfig: unknown;
11
+ let originalSyncer: unknown;
12
+
13
+ beforeEach(() => {
14
+ originalConfig = Reflect.get(Sonamu, "_config");
15
+ originalSyncer = Reflect.get(Sonamu, "_syncer");
16
+ });
17
+
18
+ afterEach(() => {
19
+ Reflect.set(Sonamu, "_config", originalConfig);
20
+ Reflect.set(Sonamu, "_syncer", originalSyncer);
21
+ });
22
+
23
+ it("reuses websocket event type names when they are importable", () => {
24
+ Reflect.set(Sonamu, "_config", {
25
+ api: {
26
+ route: {
27
+ prefix: "/api",
28
+ },
29
+ },
30
+ });
31
+
32
+ const apis: ExtendedApi[] = [
33
+ {
34
+ modelName: "ChatFrame",
35
+ methodName: "subscribeChat",
36
+ path: "/chat/subscribeChat",
37
+ options: {
38
+ httpMethod: "GET",
39
+ },
40
+ websocketOptions: {
41
+ outEvents: z.object({
42
+ ready: z.object({
43
+ ok: z.boolean(),
44
+ }),
45
+ }),
46
+ inEvents: z.object({
47
+ ping: z.object({
48
+ at: z.string(),
49
+ }),
50
+ }),
51
+ outEventsTypeRef: {
52
+ t: "ref",
53
+ id: "ChatOutEvents",
54
+ },
55
+ inEventsTypeRef: {
56
+ t: "ref",
57
+ id: "ChatInEvents",
58
+ },
59
+ },
60
+ typeParameters: [],
61
+ parameters: [],
62
+ returnType: "void",
63
+ },
64
+ ];
65
+ Reflect.set(Sonamu, "_syncer", {
66
+ apis,
67
+ });
68
+
69
+ EntityManager.setModulePath("ChatOutEvents", "chat/chat.types");
70
+ EntityManager.setModulePath("ChatInEvents", "chat/chat.types");
71
+
72
+ const template = new Template__services();
73
+ const rendered = template.render({});
74
+
75
+ expect(rendered.body).toContain("handlers: EventHandlers<ChatOutEvents>");
76
+ expect(rendered.body).toContain("useWebSocketChannel<ChatOutEvents, ChatInEvents>");
77
+ expect(rendered.importKeys).toEqual(expect.arrayContaining(["ChatOutEvents", "ChatInEvents"]));
78
+ });
79
+ });
@@ -43,6 +43,27 @@ export class Template__services extends Template {
43
43
  const importKeys: string[] = [];
44
44
  const namespaces: string[] = [];
45
45
  let typeParamNames: string[] = [];
46
+ const resolveWebSocketEventTypeDef = (
47
+ typeRef: ApiParamType.Ref | undefined,
48
+ schema: Parameters<typeof zodTypeToTsTypeDef>[0],
49
+ ): string => {
50
+ if (typeRef) {
51
+ const candidateImportKeys: string[] = [];
52
+ const candidateTypeDef = apiParamTypeToTsType(typeRef, candidateImportKeys);
53
+
54
+ try {
55
+ for (const key of unique(candidateImportKeys)) {
56
+ EntityManager.getModulePath(key);
57
+ }
58
+ importKeys.push(...candidateImportKeys);
59
+ return candidateTypeDef;
60
+ } catch {
61
+ // 로컬 const 등 import 가능한 심볼이 아니면 기존 inline 생성으로 후퇴
62
+ }
63
+ }
64
+
65
+ return zodTypeToTsTypeDef(schema);
66
+ };
46
67
 
47
68
  for (const [modelName, modelApis] of apisByModel) {
48
69
  const functions: string[] = [];
@@ -86,6 +107,52 @@ export function ${methodNameStreamCamelized}(
86
107
  continue;
87
108
  }
88
109
 
110
+ if (api.websocketOptions) {
111
+ // websocket surface는 fetch 함수 대신 typed hook을 생성함
112
+ const paramsWithoutContext = api.parameters.filter(
113
+ (param) =>
114
+ !ApiParamType.isContext(param.type) &&
115
+ !ApiParamType.isRefKnex(param.type) &&
116
+ !(param.optional && param.name.startsWith("_")),
117
+ );
118
+
119
+ const apiBaseUrl = `${Sonamu.config.api.route.prefix}${api.path}`;
120
+
121
+ const methodNameWebSocket = api.options.resourceName
122
+ ? `use${inflection.camelize(api.options.resourceName)}`
123
+ : `use${inflection.camelize(api.methodName)}`;
124
+ const methodNameWebSocketCamelized = inflection.camelize(methodNameWebSocket, true);
125
+
126
+ // outEvents는 수신 타입, inEvents는 send() 입력 타입으로 사용함
127
+ const outEventsTypeDef = resolveWebSocketEventTypeDef(
128
+ api.websocketOptions.outEventsTypeRef,
129
+ api.websocketOptions.outEvents,
130
+ );
131
+ const inEventsTypeDef = resolveWebSocketEventTypeDef(
132
+ api.websocketOptions.inEventsTypeRef,
133
+ api.websocketOptions.inEvents,
134
+ );
135
+
136
+ // context/refKnex/internal optional 파라미터는 클라이언트 입력에서 제외함
137
+ const paramsDefAsObject =
138
+ paramsWithoutContext.length > 0
139
+ ? `{ ${paramsWithoutContext.map((p) => `${p.name}: ${apiParamTypeToTsType(p.type, importKeys)}`).join(", ")} }`
140
+ : "{}";
141
+
142
+ functions.push(
143
+ `
144
+ export function ${methodNameWebSocketCamelized}(
145
+ params: ${paramsDefAsObject},
146
+ handlers: EventHandlers<${outEventsTypeDef}>,
147
+ options: WebSocketChannelOptions = {}
148
+ ) {
149
+ return useWebSocketChannel<${outEventsTypeDef}, ${inEventsTypeDef}>(\`${apiBaseUrl}\`, params, handlers, options);
150
+ }
151
+ `.trim(),
152
+ );
153
+ continue;
154
+ }
155
+
89
156
  // Context 제외한 파라미터
90
157
  const paramsWithoutContext = api.parameters.filter(
91
158
  (param) =>
@@ -397,7 +464,9 @@ export const ${names.capital}AsyncIdConfig: AsyncIdConfig<${names.capital}Subset
397
464
  "fetch",
398
465
  "type EventHandlers",
399
466
  "type SSEStreamOptions",
467
+ "type WebSocketChannelOptions",
400
468
  "useSSEStream",
469
+ "useWebSocketChannel",
401
470
  "toFormData",
402
471
  ...(needsDedupeAndFlatten ? ["dedupeAndFlatten"] : []),
403
472
  ...(needsUseRefreshable ? ["useRefreshable"] : []),
@@ -58,11 +58,18 @@ export function bootstrap(vi: VitestUtils, options?: BootstrapOptions) {
58
58
 
59
59
  function getMockContext(): Context {
60
60
  return {
61
+ transport: "http",
62
+ request: null as unknown as Context["request"],
63
+ reply: null as unknown as Context["reply"],
64
+ headers: {},
65
+ createSSE: (() => {
66
+ throw new Error("createSSE is not available in mock context");
67
+ }) as Context["createSSE"],
61
68
  session: null,
62
69
  user: null,
63
70
  naiteStore: Naite.createStore(),
64
71
  locale: "",
65
- } as unknown as Context;
72
+ };
66
73
  }
67
74
 
68
75
  export async function runWithContext(context: Context | null, fn: () => Promise<void>) {
@@ -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;
@@ -992,7 +992,8 @@ export namespace ApiParamType {
992
992
  typeof v === "object" &&
993
993
  v !== null &&
994
994
  (v as { t?: unknown }).t === "ref" &&
995
- (v as { id?: unknown }).id === "Context"
995
+ ((v as { id?: unknown }).id === "Context" ||
996
+ (v as { id?: unknown }).id === "WebSocketContext")
996
997
  );
997
998
  }
998
999
  export function isRefKnex(v: unknown): v is ApiParamType.Ref {
@@ -1774,11 +1775,28 @@ export type SonamuFastifyConfig = {
1774
1775
  contextProvider: (
1775
1776
  defaultContext: Pick<
1776
1777
  Context,
1777
- "request" | "reply" | "headers" | "createSSE" | "naiteStore" | "locale" | "user" | "session"
1778
+ | "transport"
1779
+ | "request"
1780
+ | "reply"
1781
+ | "headers"
1782
+ | "createSSE"
1783
+ | "naiteStore"
1784
+ | "locale"
1785
+ | "user"
1786
+ | "session"
1778
1787
  >,
1779
1788
  request: FastifyRequest,
1780
1789
  reply: FastifyReply,
1781
1790
  ) => Context | Promise<Context>;
1791
+ websocketContextProvider?: (
1792
+ defaultContext: Pick<
1793
+ import("../api/context").WebSocketContext,
1794
+ "transport" | "request" | "headers" | "ws" | "naiteStore" | "locale" | "user" | "session"
1795
+ >,
1796
+ request: FastifyRequest,
1797
+ ) =>
1798
+ | import("../api/context").WebSocketContext
1799
+ | Promise<import("../api/context").WebSocketContext>;
1782
1800
  guardHandler: (
1783
1801
  guard: GuardKey,
1784
1802
  request: FastifyRequest,
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
  }),
@@ -1,6 +1,9 @@
1
1
  import { glob } from "fs/promises";
2
2
  import path from "path";
3
3
 
4
+ import { minimatch } from "minimatch";
5
+ import { debounce } from "radashi";
6
+
4
7
  /**
5
8
  * 비동기 조건으로 배열을 필터링합니다
6
9
  * @example
@@ -65,13 +68,77 @@ export async function reduceAsync<T, U>(
65
68
  /**
66
69
  * 비동기 glob 함수입니다.
67
70
  * AsyncIterableIterator로 날아오는 glob의 반환을 받아 끝까지 돌아서 배열로 반환합니다.
68
- * @param pathPattern
69
- * @returns
71
+ *
72
+ * @param pathPattern glob 패턴 (절대 경로 또는 상대 경로)
73
+ * @param options.exclude 매치에서 제외할 패턴 목록 (예: `["**\/node_modules/**"]`).
74
+ * alternation을 포함하는 패턴이 의도치 않게 빌드 산출물 디렉토리를 휘말리게 하는 것을 막는 안전망.
70
75
  */
71
- export async function globAsync(pathPattern: string): Promise<string[]> {
76
+ export async function globAsync(
77
+ pathPattern: string,
78
+ options?: { exclude?: string[] },
79
+ ): Promise<string[]> {
72
80
  const files: string[] = [];
73
- for await (const file of glob(path.resolve(pathPattern))) {
81
+ const excludePatterns = options?.exclude;
82
+ const iter = excludePatterns
83
+ ? glob(path.resolve(pathPattern), {
84
+ exclude: (filePath: string) =>
85
+ excludePatterns.some((pat) => minimatch(filePath, pat, { dot: true })),
86
+ })
87
+ : glob(path.resolve(pathPattern));
88
+ for await (const file of iter) {
74
89
  files.push(file);
75
90
  }
76
91
  return files;
77
92
  }
93
+
94
+ /**
95
+ * 키별로 trailing-edge debounce. 같은 key에 대해 delay 안에 여러 번 호출되면 마지막 args만
96
+ * delay 후에 fn 호출. key가 다르면 timer 독립적.
97
+ *
98
+ * @example
99
+ * const debounced = debounceByKey<string>(100, (path) => handleFileChange(path));
100
+ * debounced("a.ts"); debounced("a.ts"); // 100ms 후 한 번만 호출
101
+ * debounced("b.ts"); // a.ts와 별개로 100ms 후 호출
102
+ */
103
+ export function debounceByKey<K, A extends unknown[]>(
104
+ delay: number,
105
+ fn: (key: K, ...args: A) => void | Promise<void>,
106
+ ): (key: K, ...args: A) => void {
107
+ const debouncersByKey = new Map<K, (key: K, ...args: A) => void>();
108
+ return (key, ...args) => {
109
+ let debounced = debouncersByKey.get(key);
110
+ if (!debounced) {
111
+ debounced = debounce({ delay }, (k: K, ...a: A) => {
112
+ void fn(k, ...a);
113
+ });
114
+ debouncersByKey.set(key, debounced);
115
+ }
116
+ debounced(key, ...args);
117
+ };
118
+ }
119
+
120
+ /**
121
+ * 같은 key에 대한 호출 결과를 캐시. 같은 key로 다시 호출되면 fn 실행 없이 캐시값 반환.
122
+ * 무효화는 프로세스 재시작 (또는 외부에서 invalidate). 호출자가 keyFn으로 args → string 매핑.
123
+ *
124
+ * @example
125
+ * const cachedFmt = cached(formatCodeInternal, (code, filePath) => `${ext(filePath)}:${sha1(code)}`);
126
+ * await cachedFmt(code, "a.ts"); // 실제 호출
127
+ * await cachedFmt(code, "a.ts"); // 캐시 hit
128
+ */
129
+ export function cached<A extends unknown[], R>(
130
+ fn: (...args: A) => Promise<R>,
131
+ keyFn: (...args: A) => string,
132
+ ): (...args: A) => Promise<R> {
133
+ const cache = new Map<string, R>();
134
+ return async (...args) => {
135
+ const key = keyFn(...args);
136
+ const hit = cache.get(key);
137
+ if (hit !== undefined) {
138
+ return hit;
139
+ }
140
+ const result = await fn(...args);
141
+ cache.set(key, result);
142
+ return result;
143
+ };
144
+ }