@cartanova/qgrid-cli 2.6.6 → 2.6.8

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 (87) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +2 -2
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +37 -16
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -3
  5. package/bundle/dist/application/qgrid/slack-user-map.js +5 -14
  6. package/bundle/dist/application/qgrid/token-death.js +3 -2
  7. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  8. package/bundle/dist/application/queries.generated.js +8 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/setting/setting.model.js +236 -0
  12. package/bundle/dist/application/setting/setting.schema.js +157 -0
  13. package/bundle/dist/application/setting/setting.store.js +66 -0
  14. package/bundle/dist/application/setting/setting.types.js +48 -0
  15. package/bundle/dist/application/sonamu.generated.js +32 -2
  16. package/bundle/dist/application/sonamu.generated.sso.js +12 -2
  17. package/bundle/dist/application/token/token.model.js +1 -1
  18. package/bundle/dist/i18n/en.js +3 -1
  19. package/bundle/dist/i18n/ko.js +3 -1
  20. package/bundle/dist/i18n/sd.generated.js +9 -1
  21. package/bundle/dist/migrations/20260808194708_create__settings.js +24 -0
  22. package/bundle/dist/sonamu.config.js +8 -2
  23. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +21 -6
  24. package/bundle/dist/utils/quiet-hours.js +43 -0
  25. package/bundle/dist/utils/slack-notify.js +12 -5
  26. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +135 -13
  27. package/bundle/src/application/qgrid/expired-token-reminder.ts +60 -21
  28. package/bundle/src/application/qgrid/slack-user-map.test.ts +23 -0
  29. package/bundle/src/application/qgrid/slack-user-map.ts +4 -14
  30. package/bundle/src/application/qgrid/token-death.ts +2 -0
  31. package/bundle/src/application/queries.generated.ts +21 -1
  32. package/bundle/src/application/setting/setting.entity.json +59 -0
  33. package/bundle/src/application/setting/setting.model.ts +263 -0
  34. package/bundle/src/application/setting/setting.schema.test.ts +80 -0
  35. package/bundle/src/application/setting/setting.schema.ts +174 -0
  36. package/bundle/src/application/setting/setting.store.test.ts +87 -0
  37. package/bundle/src/application/setting/setting.store.ts +85 -0
  38. package/bundle/src/application/setting/setting.types.ts +51 -0
  39. package/bundle/src/application/sonamu.generated.http +47 -0
  40. package/bundle/src/application/sonamu.generated.sso.ts +21 -0
  41. package/bundle/src/application/sonamu.generated.ts +51 -0
  42. package/bundle/src/i18n/en.ts +2 -0
  43. package/bundle/src/i18n/ko.ts +2 -0
  44. package/bundle/src/i18n/sd.generated.ts +8 -0
  45. package/bundle/src/migrations/20260808194708_create__settings.ts +19 -0
  46. package/bundle/src/sonamu.config.ts +10 -0
  47. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
  48. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +43 -5
  49. package/bundle/src/utils/quiet-hours.ts +40 -0
  50. package/bundle/src/utils/slack-notify.test.ts +88 -20
  51. package/bundle/src/utils/slack-notify.ts +24 -4
  52. package/bundle/web-dist/client/assets/index-7SZyiqAx.css +1 -0
  53. package/bundle/web-dist/client/assets/index-DrsXE13r.js +78 -0
  54. package/bundle/web-dist/client/assets/{logs-CvmI4KuC.js → logs-e4-6aYnO.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-CUueNzoL.js → monit-eOAlYTK9.js} +1 -1
  56. package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
  57. package/bundle/web-dist/client/assets/{routes-CAfjRsMY.js → routes-Qie4ogtJ.js} +2 -2
  58. package/bundle/web-dist/client/assets/{sd.generated-DYdwSB2I.js → sd.generated-Bwa7rses.js} +1 -1
  59. package/bundle/web-dist/client/assets/services.generated-T4ctVcia.js +1 -0
  60. package/bundle/web-dist/client/assets/settings-CTVZpAAg.js +1 -0
  61. package/bundle/web-dist/client/assets/{show-C5vKpp5z.js → show-60iOOM-e.js} +1 -1
  62. package/bundle/web-dist/client/assets/tokens-DdDccGlX.js +1 -0
  63. package/bundle/web-dist/client/assets/{use-oauth-login-flow-B3o4CXDW.js → use-oauth-login-flow-0ZGq74bZ.js} +1 -1
  64. package/bundle/web-dist/client/index.html +3 -3
  65. package/bundle/web-dist/server/assets/{lazyRouteComponent-BaZaveNc.js → lazyRouteComponent-Ws7I6fL-.js} +2 -1
  66. package/bundle/web-dist/server/assets/{logs-DMOw3SjD.js → logs-DmkzGxGU.js} +4 -4
  67. package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-DyKLJX4s.js} +3 -2
  68. package/bundle/web-dist/server/assets/plus-BISXNqRU.js +19 -0
  69. package/bundle/web-dist/server/assets/react-dom-D7duWOwf.js +462 -0
  70. package/bundle/web-dist/server/assets/{routes-BfoQmYud.js → routes-g62E9xmC.js} +5 -4
  71. package/bundle/web-dist/server/assets/{sd.generated-B0xqnHEo.js → sd.generated-LO-h1_16.js} +13 -461
  72. package/bundle/web-dist/server/assets/{services.generated-DqAKskAS.js → services.generated-DADr9OGH.js} +111 -2
  73. package/bundle/web-dist/server/assets/settings-CMJKayff.js +332 -0
  74. package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BGAiCPGq.js} +4 -4
  75. package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DmhIfVct.js} +6 -21
  76. package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-B6MY65IN.js} +2 -2
  77. package/bundle/web-dist/server/entry-server.generated.js +54 -18
  78. package/package.json +1 -1
  79. package/skills/qgrid/references/cli-env-and-server-boot.md +14 -2
  80. package/skills/qgrid/references/openai-codex-runtime.md +1 -1
  81. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  82. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -1
  83. package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
  84. package/bundle/web-dist/client/assets/index-qs8K8bOv.js +0 -78
  85. package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
  86. package/bundle/web-dist/client/assets/tokens-C9KKP2Yq.js +0 -1
  87. /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
@@ -0,0 +1,263 @@
1
+ import {
2
+ BaseModelClass,
3
+ type ListResult,
4
+ asArray,
5
+ NotFoundException,
6
+ BadRequestException,
7
+ api,
8
+ exhaustive,
9
+ } from "sonamu";
10
+
11
+ import { SD } from "../../i18n/sd.generated";
12
+ import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
13
+ import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
14
+ import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.schema";
15
+ import {
16
+ getSetting,
17
+ isStored,
18
+ resetSetting as resetStoredSetting,
19
+ saveSetting as saveStoredSetting,
20
+ } from "./setting.store";
21
+ import {
22
+ type RuntimeInfoItem,
23
+ type SettingApplies,
24
+ type SettingItem,
25
+ type SettingListParams,
26
+ type SettingSaveParams,
27
+ type SettingsResponse,
28
+ } from "./setting.types";
29
+
30
+ /*
31
+ Setting Model
32
+ */
33
+ class SettingModelClass extends BaseModelClass<
34
+ SettingSubsetKey,
35
+ SettingSubsetMapping,
36
+ typeof settingSubsetQueries,
37
+ typeof settingLoaderQueries
38
+ > {
39
+ constructor() {
40
+ super("Setting", settingSubsetQueries, settingLoaderQueries);
41
+ }
42
+
43
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "Setting" })
44
+ async findById<T extends SettingSubsetKey>(
45
+ subset: T,
46
+ id: number,
47
+ ): Promise<SettingSubsetMapping[T]> {
48
+ const { rows } = await this.findMany(subset, {
49
+ id,
50
+ num: 1,
51
+ page: 1,
52
+ });
53
+ if (!rows[0]) {
54
+ throw new NotFoundException(SD("error.entityNotFound")("Setting", id));
55
+ }
56
+
57
+ return rows[0];
58
+ }
59
+
60
+ async findOne<T extends SettingSubsetKey>(
61
+ subset: T,
62
+ listParams: SettingListParams,
63
+ ): Promise<SettingSubsetMapping[T] | null> {
64
+ const { rows } = await this.findMany(subset, {
65
+ ...listParams,
66
+ num: 1,
67
+ page: 1,
68
+ });
69
+
70
+ return rows[0] ?? null;
71
+ }
72
+
73
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "Settings" })
74
+ async findMany<T extends SettingSubsetKey, LP extends SettingListParams>(
75
+ subset: T,
76
+ rawParams?: LP,
77
+ ): Promise<ListResult<LP, SettingSubsetMapping[T]>> {
78
+ // params with defaults
79
+ const params = {
80
+ num: 24,
81
+ page: 1,
82
+ search: "key" as const,
83
+ orderBy: "key-asc" as const,
84
+ ...rawParams,
85
+ } satisfies SettingListParams;
86
+
87
+ // build queries
88
+ const { qb, onSubset: _ } = this.getSubsetQueries(subset);
89
+
90
+ // id
91
+ if (params.id) {
92
+ qb.whereIn("settings.id", asArray(params.id));
93
+ }
94
+
95
+ // key — toFilter prop 이지만 스캐폴딩이 만들어주지 않아 직접 건다.
96
+ if (params.key) {
97
+ qb.where("settings.key", params.key);
98
+ }
99
+
100
+ // search-keyword
101
+ if (params.search && params.keyword && params.keyword.length > 0) {
102
+ if (params.search === "key") {
103
+ qb.where("settings.key", "like", `%${params.keyword}%`);
104
+ } else {
105
+ throw new BadRequestException(SD("error.unknownSearchField")(params.search));
106
+ }
107
+ }
108
+
109
+ // orderBy
110
+ if (params.orderBy) {
111
+ // default orderBy
112
+ if (params.orderBy === "key-asc") {
113
+ qb.orderBy("settings.key", "asc");
114
+ } else {
115
+ exhaustive(params.orderBy);
116
+ }
117
+ }
118
+
119
+ const enhancers = this.createEnhancers({
120
+ A: (row) => ({
121
+ ...row,
122
+ // 서브셋별로 virtual 필드 계산로직 추가
123
+ }),
124
+ });
125
+
126
+ return this.executeSubsetQuery({
127
+ subset,
128
+ qb,
129
+ params,
130
+ enhancers,
131
+ debug: false,
132
+ });
133
+ }
134
+
135
+ async save(spa: SettingSaveParams[]): Promise<number[]> {
136
+ const wdb = this.getPuri("w");
137
+
138
+ // register
139
+ spa.forEach((sp) => {
140
+ wdb.ubRegister("settings", sp);
141
+ });
142
+
143
+ // transaction
144
+ return wdb.transaction(async (trx) => {
145
+ const ids = await trx.ubUpsert("settings");
146
+
147
+ return ids;
148
+ });
149
+ }
150
+
151
+ async del(ids: number[]): Promise<number> {
152
+ const wdb = this.getPuri("w");
153
+
154
+ // transaction
155
+ await wdb.transaction(async (trx) => {
156
+ return trx.table("settings").whereIn("settings.id", ids).delete();
157
+ });
158
+
159
+ return ids.length;
160
+ }
161
+
162
+ /** 저장된 설정 전체를 key→value 로. 부팅 시 한 번 읽어 메모리에 올린다. */
163
+ async findAllAsMap(): Promise<Map<string, string>> {
164
+ const { rows } = await this.findMany("A", { num: 0, page: 1 });
165
+ return new Map(rows.map((r) => [r.key, r.value]));
166
+ }
167
+
168
+ /**
169
+ * key 로 upsert 한다. `save` 는 id 기준이라 key 중복을 걸러내지 못해, 기존 행을 먼저 찾아
170
+ * id 를 실어 보낸다.
171
+ */
172
+ async setByKey(key: string, value: string): Promise<void> {
173
+ const existing = await this.findOne("A", { key, num: 1, page: 1 });
174
+ await this.save([
175
+ {
176
+ ...(existing ? { id: existing.id } : {}),
177
+ key,
178
+ value,
179
+ updated_at: new Date(),
180
+ },
181
+ ]);
182
+ }
183
+
184
+ /** 저장값을 지워 env 기본값으로 되돌린다. */
185
+ async clearByKey(key: string): Promise<void> {
186
+ const existing = await this.findOne("A", { key, num: 1, page: 1 });
187
+ if (existing) await this.del([existing.id]);
188
+ }
189
+
190
+ /**
191
+ * 편집 가능한 설정과 읽기 전용 런타임 정보. 편집 대상은 `SETTING_DEFS` 에 정의된 것뿐이다 —
192
+ * DB 접속 정보나 포트는 서버가 뜨기 전에 필요해 화면에서 바꿀 수 없다.
193
+ */
194
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "SettingList" })
195
+ async listSettings(): Promise<SettingsResponse> {
196
+ const settings: SettingItem[] = SETTING_DEFS.map((def) => {
197
+ // 저장값도 env 도 없으면 코드 기본값이 실제로 적용 중이다. 빈칸으로 두면 화면과
198
+ // 런타임이 어긋나 보인다.
199
+ const stored = isStored(def.key);
200
+ const raw = getSetting(def.key, def.envKey);
201
+ const effective = raw ?? def.fallback;
202
+ const source = stored ? "db" : raw !== undefined ? "env" : "default";
203
+
204
+ return {
205
+ key: def.key,
206
+ group: def.group,
207
+ label: def.label,
208
+ kind: def.kind,
209
+ applies: def.applies,
210
+ // 눈 토글을 위해 secret 도 원본을 내려보낸다. 이 엔드포인트는 배포 경계에서 인증해야
211
+ // 하며, dev0 는 Caddy 인증을 통과한 운영자만 접근할 수 있다.
212
+ value: effective,
213
+ source,
214
+ min: def.min ?? null,
215
+ max: def.max ?? null,
216
+ help: def.help ?? null,
217
+ };
218
+ });
219
+
220
+ return { settings, runtime: this.runtimeInfo() };
221
+ }
222
+
223
+ private runtimeInfo(): RuntimeInfoItem[] {
224
+ const env = process.env;
225
+ const password = env.QGRID_DB_PASSWORD ?? env.SONAMU_DB_PASSWORD ?? "";
226
+ return [
227
+ { label: "NODE_ENV", value: env.NODE_ENV ?? "-" },
228
+ { label: "HOST", value: env.HOST ?? "localhost" },
229
+ { label: "PORT", value: env.PORT ?? "44900" },
230
+ { label: "DB Host", value: env.QGRID_DB_HOST ?? env.SONAMU_DB_HOST ?? "-" },
231
+ { label: "DB Port", value: env.QGRID_DB_PORT ?? env.SONAMU_DB_PORT ?? "-" },
232
+ { label: "DB Name", value: env.QGRID_DB_NAME ?? env.SONAMU_DB_NAME ?? "-" },
233
+ { label: "DB User", value: env.QGRID_DB_USER ?? env.SONAMU_DB_USER ?? "-" },
234
+ { label: "DB Password", value: password ? maskSecret(password) : "-" },
235
+ ];
236
+ }
237
+
238
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
239
+ async updateSetting(key: string, value: string): Promise<{ applies: SettingApplies }> {
240
+ const def = findSettingDef(key);
241
+ if (!def) throw new BadRequestException(SD("setting.unknownKey")(key));
242
+
243
+ const validation = validateSettingValue(def, value);
244
+ if (!validation.ok) {
245
+ throw new BadRequestException(SD("setting.invalidValue")(validation.error));
246
+ }
247
+
248
+ await saveStoredSetting(key, validation.value);
249
+ return { applies: def.applies };
250
+ }
251
+
252
+ /** 저장값을 지워 env 기본값으로 되돌린다. */
253
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
254
+ async resetSetting(key: string): Promise<{ applies: SettingApplies }> {
255
+ const def = findSettingDef(key);
256
+ if (!def) throw new BadRequestException(SD("setting.unknownKey")(key));
257
+
258
+ await resetStoredSetting(key);
259
+ return { applies: def.applies };
260
+ }
261
+ }
262
+
263
+ export const SettingModel = new SettingModelClass();
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.schema";
4
+
5
+ describe("validateSettingValue", () => {
6
+ const intDef = findSettingDef("openai.maxWorkersPerToken")!;
7
+ const boolDef = findSettingDef("openai.autoscale")!;
8
+ const numDef = findSettingDef("openai.maxEstimatedRssGiB")!;
9
+
10
+ it("정수 범위를 벗어나면 거부한다", () => {
11
+ // 저장을 막지 않으면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다.
12
+ expect(validateSettingValue(intDef, "21")).toMatchObject({ ok: false });
13
+ expect(validateSettingValue(intDef, "0")).toMatchObject({ ok: false });
14
+ expect(validateSettingValue(intDef, "20")).toMatchObject({ ok: true, value: "20" });
15
+ });
16
+
17
+ it("정수 자리에 소수를 넣으면 거부한다", () => {
18
+ expect(validateSettingValue(intDef, "3.5")).toMatchObject({ ok: false });
19
+ });
20
+
21
+ it("숫자가 아닌 값을 거부한다", () => {
22
+ expect(validateSettingValue(intDef, "많이")).toMatchObject({ ok: false });
23
+ });
24
+
25
+ it("실수 설정은 소수를 허용한다", () => {
26
+ expect(validateSettingValue(numDef, "16.5")).toMatchObject({ ok: true, value: "16.5" });
27
+ });
28
+
29
+ it("boolean 은 true/false 만 받는다", () => {
30
+ expect(validateSettingValue(boolDef, "true")).toMatchObject({ ok: true });
31
+ expect(validateSettingValue(boolDef, "false")).toMatchObject({ ok: true });
32
+ expect(validateSettingValue(boolDef, "1")).toMatchObject({ ok: false });
33
+ });
34
+
35
+ it("앞뒤 공백은 저장 전에 떨어뜨린다", () => {
36
+ expect(validateSettingValue(intDef, " 7 ")).toMatchObject({ ok: true, value: "7" });
37
+ });
38
+ });
39
+
40
+ describe("maskSecret", () => {
41
+ it("긴 값은 앞뒤만 남긴다", () => {
42
+ expect(maskSecret("xoxb-1234567890-abcdefghij")).toBe("xoxb-123...ghij");
43
+ });
44
+
45
+ it("짧은 값은 통째로 가린다", () => {
46
+ // 앞뒤를 남기면 짧은 값은 사실상 전부 노출된다.
47
+ expect(maskSecret("short")).toBe("***");
48
+ });
49
+ });
50
+
51
+ describe("SETTING_DEFS", () => {
52
+ it("키가 중복되지 않는다", () => {
53
+ const keys = SETTING_DEFS.map((d) => d.key);
54
+ expect(new Set(keys).size).toBe(keys.length);
55
+ });
56
+
57
+ it("env 키도 중복되지 않는다", () => {
58
+ // 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
59
+ const envKeys = SETTING_DEFS.map((d) => d.envKey);
60
+ expect(new Set(envKeys).size).toBe(envKeys.length);
61
+ });
62
+
63
+ it("정의되지 않은 키는 찾지 못한다", () => {
64
+ expect(findSettingDef("nope.bad")).toBeUndefined();
65
+ });
66
+
67
+ it("64 GiB 호스트 기준 메모리 설정의 오입력 상한을 둔다", () => {
68
+ const rss = findSettingDef("openai.maxEstimatedRssGiB")!;
69
+ const available = findSettingDef("openai.minHostAvailableGiB")!;
70
+
71
+ expect(rss.max).toBe(32);
72
+ expect(available.max).toBe(64);
73
+ expect(validateSettingValue(rss, "32")).toMatchObject({ ok: true });
74
+ expect(validateSettingValue(rss, "32.5")).toMatchObject({ ok: false });
75
+ expect(validateSettingValue(available, "64")).toMatchObject({ ok: true });
76
+ expect(validateSettingValue(available, "64.5")).toMatchObject({ ok: false });
77
+ expect(rss.help).toContain("OOM");
78
+ expect(available.help).toContain("오토스케일");
79
+ });
80
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 관리 가능한 런타임 설정 목록.
3
+ *
4
+ * 키·env 이름·타입·검증을 한 곳에 모아 API 와 UI 가 같은 정의를 본다. 여기 없는 env 는
5
+ * 화면에서 바꿀 수 없다 — DB 접속 정보나 포트처럼 서버가 뜨기 전에 필요한 값들이다.
6
+ */
7
+
8
+ export type SettingKind = "boolean" | "integer" | "number" | "string" | "secret";
9
+
10
+ /** 화면에서 묶어 보여줄 단위. 9개를 평평하게 나열하면 어디를 봐야 할지 알 수 없다. */
11
+ export type SettingGroup = "openai" | "slack";
12
+
13
+ export type SettingDef = {
14
+ key: string;
15
+ group: SettingGroup;
16
+ envKey: string;
17
+ label: string;
18
+ kind: SettingKind;
19
+ /** 적용 시점. 워커 설정은 dispatcher 생성자에서 한 번만 읽혀 재시작이 필요하다. */
20
+ applies: "immediate" | "restart";
21
+ min?: number;
22
+ max?: number;
23
+ /**
24
+ * 저장값도 env 도 없을 때 실제로 적용되는 값. 화면을 비워두면 "설정 안 됨"으로 읽히지만
25
+ * 실제로는 이 값이 돌고 있다 — 소비처의 기본값과 반드시 같아야 한다.
26
+ */
27
+ fallback: string;
28
+ help?: string;
29
+ };
30
+
31
+ /** 64 GiB 운영 호스트에서 세 자리 오입력을 막는 설정 상한. 안전을 보장하는 값은 아니다. */
32
+ export const MAX_OPENAI_ESTIMATED_RSS_GIB = 32;
33
+ export const MAX_OPENAI_MIN_HOST_AVAILABLE_GIB = 64;
34
+
35
+ export const SETTING_DEFS: SettingDef[] = [
36
+ {
37
+ key: "openai.autoscale",
38
+ fallback: "true",
39
+ group: "openai",
40
+ envKey: "QGRID_OPENAI_AUTOSCALE",
41
+ label: "OpenAI 워커 오토스케일",
42
+ kind: "boolean",
43
+ applies: "restart",
44
+ help: "끄면 워커 수가 최소값으로 고정됩니다",
45
+ },
46
+ {
47
+ key: "openai.minWorkersPerToken",
48
+ fallback: "1",
49
+ group: "openai",
50
+ envKey: "QGRID_OPENAI_MIN_WORKERS_PER_TOKEN",
51
+ label: "토큰당 최소 워커",
52
+ kind: "integer",
53
+ applies: "restart",
54
+ min: 1,
55
+ max: 20,
56
+ },
57
+ {
58
+ key: "openai.maxWorkersPerToken",
59
+ fallback: "3",
60
+ group: "openai",
61
+ envKey: "QGRID_OPENAI_MAX_WORKERS_PER_TOKEN",
62
+ label: "토큰당 최대 워커",
63
+ kind: "integer",
64
+ applies: "restart",
65
+ min: 1,
66
+ max: 20,
67
+ help: "오토스케일이 꺼져 있으면 무시됩니다",
68
+ },
69
+ {
70
+ key: "openai.maxEstimatedRssGiB",
71
+ fallback: "16",
72
+ group: "openai",
73
+ envKey: "QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB",
74
+ label: "워커 메모리 상한 (GiB)",
75
+ kind: "number",
76
+ applies: "restart",
77
+ min: 1,
78
+ max: MAX_OPENAI_ESTIMATED_RSS_GIB,
79
+ help: "이 추정치를 넘기면 워커를 더 늘리지 않습니다. 올릴수록 OOM 위험이 커지며 상한은 오입력 방지용입니다",
80
+ },
81
+ {
82
+ key: "openai.minHostAvailableGiB",
83
+ fallback: "20",
84
+ group: "openai",
85
+ envKey: "QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB",
86
+ label: "호스트 여유 메모리 하한 (GiB)",
87
+ kind: "number",
88
+ applies: "restart",
89
+ min: 0,
90
+ max: MAX_OPENAI_MIN_HOST_AVAILABLE_GIB,
91
+ help: "호스트 여유가 이 값 아래면 스케일업을 멈춥니다. 확장을 끄려면 오토스케일을 끄세요",
92
+ },
93
+ {
94
+ key: "slack.expiryReminderMinutes",
95
+ fallback: "0",
96
+ group: "slack",
97
+ envKey: "SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES",
98
+ label: "만료 알림 반복 주기 (분)",
99
+ kind: "integer",
100
+ applies: "immediate",
101
+ min: 0,
102
+ max: 1440,
103
+ help: "0 이면 반복 알림을 끕니다",
104
+ },
105
+ {
106
+ key: "slack.channelId",
107
+ fallback: "",
108
+ group: "slack",
109
+ envKey: "SLACK_CHANNEL_ID",
110
+ label: "Slack 채널 ID",
111
+ kind: "string",
112
+ applies: "immediate",
113
+ help: "사용자 ID(U...)를 넣으면 그 사람 DM 으로 갑니다",
114
+ },
115
+ {
116
+ key: "slack.userMap",
117
+ fallback: "",
118
+ group: "slack",
119
+ envKey: "SLACK_USER_MAP",
120
+ label: "Slack 사용자 매핑",
121
+ kind: "string",
122
+ applies: "immediate",
123
+ help: "토큰명:SlackUserId 를 쉼표로. 공용 계정은 넣지 않습니다",
124
+ },
125
+ {
126
+ key: "slack.botToken",
127
+ fallback: "",
128
+ group: "slack",
129
+ envKey: "SLACK_BOT_TOKEN",
130
+ label: "Slack 봇 토큰",
131
+ kind: "secret",
132
+ applies: "immediate",
133
+ },
134
+ ];
135
+
136
+ export function findSettingDef(key: string): SettingDef | undefined {
137
+ return SETTING_DEFS.find((d) => d.key === key);
138
+ }
139
+
140
+ /** 저장 전 검증. 잘못된 값이 들어가면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다. */
141
+ export function validateSettingValue(
142
+ def: SettingDef,
143
+ raw: string,
144
+ ): { ok: true; value: string } | { ok: false; error: string } {
145
+ const trimmed = raw.trim();
146
+
147
+ if (def.kind === "boolean") {
148
+ if (!["true", "false"].includes(trimmed)) return { ok: false, error: "true 또는 false" };
149
+ return { ok: true, value: trimmed };
150
+ }
151
+
152
+ if (def.kind === "integer" || def.kind === "number") {
153
+ const parsed = Number(trimmed);
154
+ if (!Number.isFinite(parsed)) return { ok: false, error: "숫자를 입력하세요" };
155
+ if (def.kind === "integer" && !Number.isInteger(parsed)) {
156
+ return { ok: false, error: "정수를 입력하세요" };
157
+ }
158
+ if (def.min !== undefined && parsed < def.min) {
159
+ return { ok: false, error: `${def.min} 이상이어야 합니다` };
160
+ }
161
+ if (def.max !== undefined && parsed > def.max) {
162
+ return { ok: false, error: `${def.max} 이하여야 합니다` };
163
+ }
164
+ return { ok: true, value: String(parsed) };
165
+ }
166
+
167
+ return { ok: true, value: trimmed };
168
+ }
169
+
170
+ /** 화면에 그대로 내려보내면 안 되는 값. 앞뒤만 남긴다. */
171
+ export function maskSecret(value: string): string {
172
+ if (value.length <= 12) return "***";
173
+ return `${value.slice(0, 8)}...${value.slice(-4)}`;
174
+ }
@@ -0,0 +1,87 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ getSetting,
5
+ isStored,
6
+ loadSettings,
7
+ resetSetting,
8
+ saveSetting,
9
+ setSettingChangeHandler,
10
+ } from "./setting.store";
11
+
12
+ const settingModelMock = {
13
+ clearByKey: vi.fn(),
14
+ findAllAsMap: vi.fn(),
15
+ setByKey: vi.fn(),
16
+ };
17
+
18
+ describe("setting change handler", () => {
19
+ beforeEach(async () => {
20
+ settingModelMock.clearByKey.mockReset();
21
+ settingModelMock.findAllAsMap.mockReset();
22
+ settingModelMock.setByKey.mockReset();
23
+ settingModelMock.clearByKey.mockResolvedValue(undefined);
24
+ settingModelMock.findAllAsMap.mockResolvedValue(new Map());
25
+ settingModelMock.setByKey.mockResolvedValue(undefined);
26
+ setSettingChangeHandler(null);
27
+ await loadSettings(settingModelMock);
28
+ });
29
+
30
+ it("DB 저장과 캐시 갱신이 끝난 뒤 변경을 알린다", async () => {
31
+ const observed: Array<[string, string | undefined]> = [];
32
+ setSettingChangeHandler((key) => {
33
+ observed.push([key, getSetting(key, "TEST_SETTING")]);
34
+ });
35
+
36
+ await saveSetting("slack.userMap", "yds:U-NEW", settingModelMock);
37
+
38
+ expect(settingModelMock.setByKey).toHaveBeenCalledWith("slack.userMap", "yds:U-NEW");
39
+ expect(observed).toEqual([["slack.userMap", "yds:U-NEW"]]);
40
+ });
41
+
42
+ it("reset 후 저장값을 지운 상태로 변경을 알린다", async () => {
43
+ settingModelMock.findAllAsMap.mockResolvedValueOnce(
44
+ new Map([["slack.expiryReminderMinutes", "30"]]),
45
+ );
46
+ await loadSettings(settingModelMock);
47
+ const observed: boolean[] = [];
48
+ setSettingChangeHandler((key) => observed.push(isStored(key)));
49
+
50
+ await resetSetting("slack.expiryReminderMinutes", settingModelMock);
51
+
52
+ expect(settingModelMock.clearByKey).toHaveBeenCalledWith("slack.expiryReminderMinutes");
53
+ expect(observed).toEqual([false]);
54
+ });
55
+
56
+ it("설정 로드는 변경 이벤트를 만들지 않는다", async () => {
57
+ const handler = vi.fn();
58
+ setSettingChangeHandler(handler);
59
+ settingModelMock.findAllAsMap.mockResolvedValueOnce(new Map([["slack.userMap", "yds:U1"]]));
60
+
61
+ await loadSettings(settingModelMock);
62
+
63
+ expect(handler).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("handler 실패가 이미 성공한 저장을 실패로 바꾸지 않는다", async () => {
67
+ setSettingChangeHandler(() => {
68
+ throw new Error("reschedule failed");
69
+ });
70
+
71
+ await expect(saveSetting("slack.userMap", "yds:U1", settingModelMock)).resolves.toBeUndefined();
72
+ expect(getSetting("slack.userMap", "TEST_SETTING")).toBe("yds:U1");
73
+ });
74
+
75
+ it("DB 저장이 실패하면 캐시와 handler를 건드리지 않는다", async () => {
76
+ const handler = vi.fn();
77
+ setSettingChangeHandler(handler);
78
+ settingModelMock.setByKey.mockRejectedValueOnce(new Error("db failed"));
79
+
80
+ await expect(saveSetting("slack.userMap", "yds:U1", settingModelMock)).rejects.toThrow(
81
+ "db failed",
82
+ );
83
+
84
+ expect(getSetting("slack.userMap", "TEST_SETTING")).toBeUndefined();
85
+ expect(handler).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 런타임 설정 조회 캐시.
3
+ *
4
+ * 값의 출처는 DB(`settings`) → env 순이다. env 를 남겨두는 이유는 전환 때문이다 — dev0 는
5
+ * `ecosystem.config.json` 으로 값을 주입하는데, DB 만 보게 만들면 배포 직후 설정이 전부
6
+ * 기본값으로 떨어진다.
7
+ *
8
+ * 설정은 요청 경로(워커 스케일링, 알림 전송)에서 읽히므로 매번 DB 를 때리지 않고 메모리에
9
+ * 둔다. `immediate` 변경도 현재 API 프로세스에만 즉시 반영된다. 인스턴스가 여러 개면 다른
10
+ * 인스턴스의 캐시는 갱신되지 않으며, 이 범위에서는 프로세스 간 전파를 두지 않는다.
11
+ */
12
+ import { getLogger } from "@logtape/logtape";
13
+
14
+ import { SettingModel } from "./setting.model";
15
+
16
+ const logger = getLogger(["qgrid", "setting"]);
17
+
18
+ /** 로드 전에는 null 이라 전부 env 로 떨어진다. */
19
+ let cache: Map<string, string> | null = null;
20
+
21
+ type SettingChangeHandler = (key: string) => void;
22
+ export type SettingPersistence = Pick<
23
+ typeof SettingModel,
24
+ "clearByKey" | "findAllAsMap" | "setByKey"
25
+ >;
26
+ let settingChangeHandler: SettingChangeHandler | null = null;
27
+
28
+ /**
29
+ * 런타임 소비자는 상위 조립 계층에서 하나만 등록한다. handler 는 동기 작업만 해야 하며,
30
+ * 실패해도 이미 성공한 DB 저장을 실패로 바꾸지 않는다.
31
+ */
32
+ export function setSettingChangeHandler(handler: SettingChangeHandler | null): void {
33
+ settingChangeHandler = handler;
34
+ }
35
+
36
+ function notifySettingChanged(key: string): void {
37
+ try {
38
+ settingChangeHandler?.(key);
39
+ } catch (e) {
40
+ const message = e instanceof Error ? e.message : String(e);
41
+ logger.warn(`setting change handler failed for ${key}: ${message}`);
42
+ }
43
+ }
44
+
45
+ export async function loadSettings(persistence: SettingPersistence = SettingModel): Promise<void> {
46
+ try {
47
+ cache = await persistence.findAllAsMap();
48
+ logger.info(`settings loaded: ${cache.size} stored`);
49
+ } catch (e) {
50
+ // 테이블이 아직 없는 배포(마이그레이션 전)에서도 서버는 떠야 한다.
51
+ cache = new Map();
52
+ logger.warn(`settings load failed, using env only: ${(e as Error).message}`);
53
+ }
54
+ }
55
+
56
+ /** DB → env 순. 둘 다 없으면 undefined 라 호출부의 기본값으로 떨어진다. */
57
+ export function getSetting(key: string, envKey: string): string | undefined {
58
+ return cache?.get(key) ?? process.env[envKey];
59
+ }
60
+
61
+ /** 저장된 값인지 — 화면에서 출처를 구분해 보여준다. */
62
+ export function isStored(key: string): boolean {
63
+ return cache?.has(key) ?? false;
64
+ }
65
+
66
+ export async function saveSetting(
67
+ key: string,
68
+ value: string,
69
+ persistence: SettingPersistence = SettingModel,
70
+ ): Promise<void> {
71
+ await persistence.setByKey(key, value);
72
+ cache ??= new Map();
73
+ cache.set(key, value);
74
+ notifySettingChanged(key);
75
+ }
76
+
77
+ /** 저장값을 지워 env 기본값으로 되돌린다. */
78
+ export async function resetSetting(
79
+ key: string,
80
+ persistence: SettingPersistence = SettingModel,
81
+ ): Promise<void> {
82
+ await persistence.clearByKey(key);
83
+ cache?.delete(key);
84
+ notifySettingChanged(key);
85
+ }