@cartanova/qgrid-cli 1.7.3 → 1.8.0

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 (61) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +45 -32
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +7 -22
  3. package/bundle/dist/application/qgrid/qgrid.types.js +10 -3
  4. package/bundle/dist/application/qgrid/token-subscriber.js +149 -0
  5. package/bundle/dist/application/qgrid/token-trigger-setup.js +69 -0
  6. package/bundle/dist/application/sonamu.generated.sso.js +1 -1
  7. package/bundle/dist/application/token/token.types.js +1 -1
  8. package/bundle/dist/i18n/sd.generated.js +2 -2
  9. package/bundle/dist/migrations/20260410171405_create__request_logs.js +1 -1
  10. package/bundle/dist/migrations/20260410171406_create__tokens.js +1 -1
  11. package/bundle/dist/migrations/20260411120624_alter_tokens_alter2.js +1 -1
  12. package/bundle/dist/migrations/20260415134649_alter_request_logs_alter1.js +1 -1
  13. package/bundle/dist/migrations/20260421223627_alter_request_logs_add1_alter1.js +1 -1
  14. package/bundle/dist/migrations/20260423180818_alter_request_logs_add1_alter1.js +1 -1
  15. package/bundle/dist/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.js +1 -1
  16. package/bundle/dist/migrations/20260423183613_alter_request_logs_add1_alter1.js +1 -1
  17. package/bundle/dist/sonamu.config.js +22 -36
  18. package/bundle/dist/testing/setup-mocks.js +1 -1
  19. package/bundle/dist/utils/subset-loaders.js +1 -1
  20. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +68 -32
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +8 -25
  22. package/bundle/src/application/qgrid/qgrid.types.ts +9 -0
  23. package/bundle/src/application/qgrid/token-subscriber.ts +176 -0
  24. package/bundle/src/application/qgrid/token-trigger-setup.ts +80 -0
  25. package/bundle/src/application/request-log/request-log.entity.json +3 -1
  26. package/bundle/src/application/sonamu.generated.sso.ts +5 -5
  27. package/bundle/src/application/token/token.types.ts +1 -1
  28. package/bundle/src/i18n/sd.generated.ts +1 -1
  29. package/bundle/src/migrations/20260410171405_create__request_logs.ts +1 -1
  30. package/bundle/src/migrations/20260410171406_create__tokens.ts +1 -1
  31. package/bundle/src/migrations/20260411120624_alter_tokens_alter2.ts +1 -1
  32. package/bundle/src/migrations/20260415134649_alter_request_logs_alter1.ts +1 -1
  33. package/bundle/src/migrations/20260421223627_alter_request_logs_add1_alter1.ts +1 -1
  34. package/bundle/src/migrations/20260423180818_alter_request_logs_add1_alter1.ts +1 -1
  35. package/bundle/src/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.ts +1 -1
  36. package/bundle/src/migrations/20260423183613_alter_request_logs_add1_alter1.ts +1 -1
  37. package/bundle/src/sonamu.config.ts +33 -39
  38. package/bundle/src/testing/setup-mocks.ts +1 -1
  39. package/bundle/src/typings/fastify.d.ts +1 -1
  40. package/bundle/src/typings/sonamu.d.ts +3 -2
  41. package/bundle/src/utils/subset-loaders.ts +1 -1
  42. package/bundle/web-dist/client/assets/{index-DUmwX7rR.js → index-DzEE3oyG.js} +3 -3
  43. package/bundle/web-dist/client/assets/{logs-ci_3vzTT.js → logs-Bn56eAAL.js} +1 -1
  44. package/bundle/web-dist/client/assets/{routes-Ch5LFN0K.js → routes-BCEPwpzm.js} +1 -1
  45. package/bundle/web-dist/client/assets/{sd.generated-CIOZewtL.js → sd.generated-C9WnMJm4.js} +1 -1
  46. package/bundle/web-dist/client/assets/{services.generated-BrB-RELO.js → services.generated-CWN1-bYW.js} +1 -1
  47. package/bundle/web-dist/client/assets/{show-D3N15Krg.js → show-Ch7LEdBK.js} +1 -1
  48. package/bundle/web-dist/client/assets/tokens-DPvuQIs2.js +1 -0
  49. package/bundle/web-dist/client/index.html +2 -2
  50. package/bundle/web-dist/server/assets/{copy-CKCGxwII.js → copy-SIUvcEcZ.js} +1 -1
  51. package/bundle/web-dist/server/assets/{lazyRouteComponent-BlvOfJIF.js → lazyRouteComponent-CcyhCAoL.js} +2 -2
  52. package/bundle/web-dist/server/assets/{logs-CDacVwTQ.js → logs-D1ezT2IS.js} +3 -3
  53. package/bundle/web-dist/server/assets/{routes-BLm-zF86.js → routes-CtTFrsIz.js} +2 -2
  54. package/bundle/web-dist/server/assets/{sd.generated-CIg_jf8e.js → sd.generated-CkiEsOwK.js} +1 -1
  55. package/bundle/web-dist/server/assets/{services.generated-wCfUuY7Z.js → services.generated-D4asAlWb.js} +1 -1
  56. package/bundle/web-dist/server/assets/{shim-Dq_Izqnc.js → shim-BYpcQGN1.js} +1 -1
  57. package/bundle/web-dist/server/assets/{show-DjJyZunM.js → show-Bz0If-L7.js} +4 -4
  58. package/bundle/web-dist/server/assets/{tokens-rQIIgqMo.js → tokens-BBBfLC7N.js} +11 -4
  59. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  60. package/package.json +1 -1
  61. package/bundle/web-dist/client/assets/tokens-BAlIaa6X.js +0 -1
@@ -4,6 +4,8 @@
4
4
  * - 매 요청마다 새 claude CLI 프로세스 spawn → 응답 후 종료
5
5
  * - system 은 --append-system-prompt 로 분리 전달 (user turn 오염 방지)
6
6
  * - least-used round-robin 으로 토큰 선택
7
+ * - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
8
+ * - query 시 expires_at 비교 → 임박 시 preemptive refresh
7
9
  * - QuotaError 는 그대로 상위 전파 (자동 failover 없음, UI 에서 수동 토글)
8
10
  *
9
11
  * env allowlist: PATH, TMPDIR, CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_DISABLE_*
@@ -13,8 +15,10 @@ import { mkdirSync, writeFileSync } from "node:fs";
13
15
 
14
16
  import { getLogger } from "@logtape/logtape";
15
17
 
18
+ import { type TokenSubsetA } from "../sonamu.generated";
16
19
  import { type CliResult, type QueryInput, type TokenStats } from "./qgrid.types";
17
20
  import { maskToken, ProcessError, QuotaError, TimeoutError } from "./qgrid.types";
21
+ import { type TokenSubscriber } from "./token-subscriber";
18
22
 
19
23
  const logger = getLogger(["qgrid"]);
20
24
 
@@ -32,11 +36,19 @@ const QGRID_CLAUDE_SETTINGS = {
32
36
  cleanupPeriodDays: 1,
33
37
  };
34
38
 
35
- class QgridDispatcherClass {
36
- tokens = new Map<string, string>();
39
+ // 토큰 만료 임박 임계값 — token.expiredAt이 1분 안에 만료된다면 query 시 체크하고 refresh.
40
+ const REFRESH_SAFETY_MS = 60_000;
41
+
42
+ export class QgridDispatcherClass {
43
+ tokens = new Map<number, TokenSubsetA>();
44
+
45
+ // key기반(tokenName) 누적 카운터. token 이 OAuth refresh 로 로테이트되도 tokenName 은 불변이라 카운터 유지됨
37
46
  requestCounts = new Map<string, number>();
38
47
  rrIndex = 0;
39
48
 
49
+ // sonamu.config onStart 에서 처리하는 변수
50
+ subscriber: TokenSubscriber | null = null;
51
+
40
52
  constructor() {
41
53
  mkdirSync(`${CLAUDE_CWD}/.claude`, { recursive: true });
42
54
  writeFileSync(
@@ -45,51 +57,75 @@ class QgridDispatcherClass {
45
57
  );
46
58
  }
47
59
 
60
+ countOf(name: string): number {
61
+ return this.requestCounts.get(name) ?? 0;
62
+ }
63
+
64
+ // TokenSubscriber 콜백 — 캐시 mutation
65
+ upsertCache(id: number, row: TokenSubsetA): void {
66
+ this.tokens.set(id, row);
67
+ }
68
+
69
+ removeCache(id: number): void {
70
+ this.tokens.delete(id);
71
+ }
72
+
73
+ replaceCache(rows: TokenSubsetA[]): void {
74
+ this.tokens = new Map(rows.map((r) => [r.id, r]));
75
+ }
76
+
48
77
  getStats(): TokenStats[] {
49
- return [...this.tokens.entries()].map(([token, name]) => ({
50
- token,
51
- name,
52
- requests: this.requestCounts.get(token) ?? 0,
78
+ return [...this.tokens.values()].map((r) => ({
79
+ token: r.token,
80
+ name: r.name,
81
+ requests: this.countOf(r.name),
53
82
  }));
54
83
  }
55
84
 
56
- selectToken(): { token: string; name: string } | null {
57
- const entries = [...this.tokens.entries()];
58
- if (entries.length === 0) return null;
85
+ selectToken(): TokenSubsetA | null {
86
+ const rows = [...this.tokens.values()];
87
+ if (rows.length === 0) return null;
59
88
 
60
- const minCount = Math.min(...entries.map(([t]) => this.requestCounts.get(t) ?? 0));
61
- const idle = entries.filter(([t]) => (this.requestCounts.get(t) ?? 0) === minCount);
89
+ const minCount = Math.min(...rows.map((r) => this.countOf(r.name)));
90
+ const idle = rows.filter((r) => this.countOf(r.name) === minCount);
62
91
  const picked = idle[this.rrIndex % idle.length]!;
63
92
  this.rrIndex++;
64
- return { token: picked[0], name: picked[1] };
93
+ return picked;
65
94
  }
66
95
 
67
96
  async query(input: QueryInput, timeoutMs?: number): Promise<CliResult> {
68
- const sel = this.selectToken();
69
- if (!sel) throw new QuotaError("No tokens available");
97
+ const electedToken = this.selectToken();
98
+ if (!electedToken) throw new QuotaError("No tokens available");
70
99
 
71
100
  // await 전에 count 선반영. 병렬 요청이 동시에 도착해도 각자 다른 토큰을 고르도록.
72
- this.requestCounts.set(sel.token, (this.requestCounts.get(sel.token) ?? 0) + 1);
73
-
74
- logger.info(`→ ${sel.name} (model: ${input.model ?? DEFAULT_MODEL})`);
75
-
76
- const result = await executeClaude(input, sel.token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
77
- return { ...result, tokenName: sel.name, model: input.model ?? DEFAULT_MODEL };
78
- }
101
+ this.requestCounts.set(electedToken.name, this.countOf(electedToken.name) + 1);
79
102
 
80
- addToken(token: string, name: string): void {
81
- if (this.tokens.has(token)) return;
82
- this.tokens.set(token, name);
83
- this.requestCounts.set(token, 0);
84
- }
103
+ let token = electedToken.token;
104
+ // expires_at 임박이면 refresh.
105
+ // refresh 후 DB save → trigger NOTIFY → subscriber 가 받아 캐시 갱신 (다른 dispatcher 도 동기화)
106
+ if (
107
+ electedToken.expires_at &&
108
+ Number(electedToken.expires_at) - Date.now() < REFRESH_SAFETY_MS &&
109
+ electedToken.refresh_token
110
+ ) {
111
+ try {
112
+ const { QgridFrame } = await import("./qgrid.frame");
113
+ token = await QgridFrame.refreshToken({
114
+ id: electedToken.id,
115
+ token: electedToken.token,
116
+ name: electedToken.name,
117
+ refresh_token: electedToken.refresh_token,
118
+ });
119
+ } catch (e) {
120
+ logger.warn(`refresh failed for ${electedToken.name}: ${(e as Error).message}`);
121
+ // refresh 실패 시 기존 token 으로 진행. executeClaude 가 401 받으면 caller 처리.
122
+ }
123
+ }
85
124
 
86
- removeToken(token: string): void {
87
- this.tokens.delete(token);
88
- this.requestCounts.delete(token);
89
- }
125
+ logger.info(`→ ${electedToken.name} (model: ${input.model ?? DEFAULT_MODEL})`);
90
126
 
91
- hasToken(token: string): boolean {
92
- return this.tokens.has(token);
127
+ const result = await executeClaude(input, token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
128
+ return { ...result, tokenName: electedToken.name, model: input.model ?? DEFAULT_MODEL };
93
129
  }
94
130
  }
95
131
 
@@ -85,7 +85,6 @@ class QgridFrameClass extends BaseFrameClass {
85
85
  ...(refreshToken && refreshToken.length > 0 ? { refresh_token: refreshToken } : {}),
86
86
  },
87
87
  ]);
88
- QgridDispatcher.addToken(token, name);
89
88
  return { added: true };
90
89
  }
91
90
 
@@ -109,28 +108,19 @@ class QgridFrameClass extends BaseFrameClass {
109
108
  ...(hasRefreshToken ? { refresh_token: refreshToken } : {}),
110
109
  },
111
110
  ]);
112
-
113
- if (hasNewToken) {
114
- QgridDispatcher.removeToken(token);
115
- QgridDispatcher.addToken(newToken, name ?? entry.name);
116
- }
117
111
  return { updated: true };
118
112
  }
119
113
 
120
114
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
121
115
  async removeToken(token: string): Promise<{ removed: boolean }> {
122
- if (!QgridDispatcher.hasToken(token)) return { removed: false };
123
-
124
116
  const entry = await TokenModel.findByToken("A", token);
125
- if (entry) await TokenModel.del([entry.id]);
126
- QgridDispatcher.removeToken(token);
117
+ if (!entry) return { removed: false };
118
+ await TokenModel.del([entry.id]);
127
119
  return { removed: true };
128
120
  }
129
121
 
130
122
  /**
131
- * @param id token_id
132
- * 토큰 활성화/비활성화 토글 (dispatcher 등록/제거 & DB의 active 필드 업데이트)
133
- * @returns 토큰 활성화 여부
123
+ * 토큰 활성화/비활성화 토글 DB의 active 필드 업데이트
134
124
  */
135
125
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
136
126
  async toggleToken(id: number): Promise<{ active: boolean }> {
@@ -139,13 +129,6 @@ class QgridFrameClass extends BaseFrameClass {
139
129
 
140
130
  const newActive = !entry.active;
141
131
  await TokenModel.save([{ id, token: entry.token, active: newActive, name: entry.name }]);
142
-
143
- if (newActive) {
144
- QgridDispatcher.addToken(entry.token, entry.name);
145
- } else {
146
- QgridDispatcher.removeToken(entry.token);
147
- }
148
-
149
132
  return { active: newActive };
150
133
  }
151
134
 
@@ -181,7 +164,6 @@ class QgridFrameClass extends BaseFrameClass {
181
164
  if (tokens.accountUuid) {
182
165
  const oldEntries = await TokenModel.findByAccountUuid("A", tokens.accountUuid);
183
166
  if (oldEntries.length > 0) {
184
- for (const old of oldEntries) QgridDispatcher.removeToken(old.token);
185
167
  await TokenModel.del(oldEntries.map((o) => o.id));
186
168
  }
187
169
  }
@@ -195,7 +177,6 @@ class QgridFrameClass extends BaseFrameClass {
195
177
  account_uuid: tokens.accountUuid,
196
178
  },
197
179
  ]);
198
- QgridDispatcher.addToken(tokens.accessToken, pending.name);
199
180
 
200
181
  return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
201
182
  } catch (e) {
@@ -251,14 +232,16 @@ class QgridFrameClass extends BaseFrameClass {
251
232
  name: token.name ?? "",
252
233
  },
253
234
  ]);
254
- QgridDispatcher.removeToken(token.token);
255
- QgridDispatcher.addToken(refreshed.accessToken, token.name);
256
235
  return refreshed.accessToken;
257
236
  }
258
237
 
259
238
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
260
239
  async health(): Promise<HealthResponse> {
261
- return { status: "ok", activeTokens: QgridDispatcher.tokens.size };
240
+ return {
241
+ status: "ok",
242
+ activeTokens: QgridDispatcher.tokens.size,
243
+ subscriber: QgridDispatcher.subscriber?.status() ?? null,
244
+ };
262
245
  }
263
246
  }
264
247
 
@@ -82,9 +82,18 @@ export type UsageResponse = z.infer<typeof UsageResponse>;
82
82
 
83
83
  // ─── Health ───
84
84
 
85
+ export const SubscriberStatus = z.object({
86
+ connected: z.boolean(),
87
+ connectedAt: z.date().nullable(),
88
+ lastReconcileAt: z.date().nullable(),
89
+ attempt: z.number(),
90
+ });
91
+ export type SubscriberStatus = z.infer<typeof SubscriberStatus>;
92
+
85
93
  export const HealthResponse = z.object({
86
94
  status: z.string(),
87
95
  activeTokens: z.number(),
96
+ subscriber: SubscriberStatus.nullable(),
88
97
  });
89
98
  export type HealthResponse = z.infer<typeof HealthResponse>;
90
99
 
@@ -0,0 +1,176 @@
1
+ /** tokens_changed LISTEN subscriber. */
2
+ import { getLogger } from "@logtape/logtape";
3
+ import { Client, type ClientConfig } from "pg";
4
+
5
+ import { TokenModel } from "../token/token.model";
6
+ import { type QgridDispatcherClass } from "./qgrid.dispatcher";
7
+ import { type SubscriberStatus } from "./qgrid.types";
8
+
9
+ const logger = getLogger(["qgrid", "subscriber"]);
10
+
11
+ const TOKENS_CHANGED = "tokens_changed";
12
+ const RECONCILE_INTERVAL_MS = 10 * 60 * 1000;
13
+ const CONNECTION_TIMEOUT_MS = 5_000;
14
+ const BACKOFF_BASE_MS = 500;
15
+ const BACKOFF_CAP_MS = 30_000;
16
+
17
+ type Payload = { op: "INSERT" | "UPDATE" | "DELETE"; id: number };
18
+
19
+ export class TokenSubscriber {
20
+ client: Client | null = null;
21
+ reconcileTimer: NodeJS.Timeout | null = null;
22
+ reconnectTimer: NodeJS.Timeout | null = null;
23
+ shutdownRequested = false;
24
+ attempt = 0;
25
+ connectedAt: Date | null = null;
26
+ lastReconcileAt: Date | null = null;
27
+
28
+ constructor(
29
+ public connConfig: ClientConfig,
30
+ public dispatcher: QgridDispatcherClass,
31
+ ) {}
32
+
33
+ async start(): Promise<boolean> {
34
+ this.shutdownRequested = false;
35
+ this.ensureReconcileTimer();
36
+
37
+ try {
38
+ await this.connectAndReconcile();
39
+ return true;
40
+ } catch (e) {
41
+ logger.warn(`subscriber start failed: ${(e as Error).message}, scheduling retry`);
42
+ this.retryLater();
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async stop(): Promise<void> {
48
+ this.shutdownRequested = true;
49
+ if (this.reconcileTimer) {
50
+ clearInterval(this.reconcileTimer);
51
+ this.reconcileTimer = null;
52
+ }
53
+ if (this.reconnectTimer) {
54
+ clearTimeout(this.reconnectTimer);
55
+ this.reconnectTimer = null;
56
+ }
57
+ await this.closeClient();
58
+ }
59
+
60
+ status(): SubscriberStatus {
61
+ return {
62
+ connected: this.client !== null && this.reconnectTimer === null,
63
+ connectedAt: this.connectedAt,
64
+ lastReconcileAt: this.lastReconcileAt,
65
+ attempt: this.attempt,
66
+ };
67
+ }
68
+
69
+ ensureReconcileTimer(): void {
70
+ if (this.reconcileTimer) return;
71
+
72
+ // LISTEN/NOTIFY 는 끊긴 동안 유실될 수 있어 주기적으로 DB 기준으로 맞춘다.
73
+ this.reconcileTimer = setInterval(() => {
74
+ this.reconcile().catch((e) =>
75
+ logger.warn(`periodic reconcile failed: ${(e as Error).message}`),
76
+ );
77
+ }, RECONCILE_INTERVAL_MS);
78
+ }
79
+
80
+ async connectAndReconcile(): Promise<void> {
81
+ await this.connectAndListen();
82
+ await this.reconcile();
83
+ this.attempt = 0;
84
+ }
85
+
86
+ async connectAndListen(): Promise<void> {
87
+ const client = new Client({
88
+ ...this.connConfig,
89
+ connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
90
+ keepAlive: true,
91
+ keepAliveInitialDelayMillis: 10_000,
92
+ application_name: "qgrid-listener",
93
+ });
94
+
95
+ client.on("error", (e) => {
96
+ logger.warn(`subscriber error: ${e.message}`);
97
+ this.retryLater();
98
+ });
99
+ client.on("end", () => {
100
+ if (!this.shutdownRequested) this.retryLater();
101
+ });
102
+ client.on("notification", (msg) => {
103
+ if (msg.channel !== TOKENS_CHANGED || !msg.payload) return;
104
+ this.handleNotification(msg.payload).catch((e) =>
105
+ logger.warn(`handle NOTIFY failed: ${(e as Error).message}`),
106
+ );
107
+ });
108
+
109
+ try {
110
+ await client.connect();
111
+ await client.query("SET statement_timeout = 0");
112
+ await client.query("SET idle_in_transaction_session_timeout = 0");
113
+ await client.query(`LISTEN ${client.escapeIdentifier(TOKENS_CHANGED)}`);
114
+ } catch (e) {
115
+ client.removeAllListeners();
116
+ await client.end().catch(() => {});
117
+ throw e;
118
+ }
119
+
120
+ this.client = client;
121
+ this.connectedAt = new Date();
122
+ logger.info(`subscribed to ${TOKENS_CHANGED}`);
123
+ }
124
+
125
+ retryLater(): void {
126
+ if (this.shutdownRequested) return;
127
+ if (this.reconnectTimer) return;
128
+
129
+ void this.closeClient();
130
+ this.attempt += 1;
131
+ const cap = Math.min(BACKOFF_BASE_MS * 2 ** this.attempt, BACKOFF_CAP_MS);
132
+ const delay = Math.floor(Math.random() * cap);
133
+ logger.info(`reconnecting in ${delay}ms (attempt ${this.attempt})`);
134
+ this.reconnectTimer = setTimeout(() => {
135
+ this.reconnectTimer = null;
136
+ this.connectAndReconcile().catch((e) => {
137
+ logger.warn(`subscriber reconnect failed: ${(e as Error).message}`);
138
+ this.retryLater();
139
+ });
140
+ }, delay);
141
+ }
142
+
143
+ async closeClient(): Promise<void> {
144
+ const client = this.client;
145
+ if (!client) return;
146
+
147
+ this.client = null;
148
+ client.removeAllListeners();
149
+ await client.end().catch(() => {});
150
+ }
151
+
152
+ async handleNotification(payloadJson: string): Promise<void> {
153
+ const payload = JSON.parse(payloadJson) as Payload;
154
+ if (payload.op === "DELETE") {
155
+ this.dispatcher.removeCache(payload.id);
156
+ logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
157
+ return;
158
+ }
159
+ const row = await TokenModel.findOne("A", { id: payload.id });
160
+ if (!row || !row.active) {
161
+ this.dispatcher.removeCache(payload.id);
162
+ logger.info(
163
+ `NOTIFY ${payload.op} id=${payload.id} (${row?.name ?? "?"}) active=${row?.active ?? "missing"} → removed from cache`,
164
+ );
165
+ return;
166
+ }
167
+ this.dispatcher.upsertCache(payload.id, row);
168
+ logger.info(`NOTIFY ${payload.op} id=${payload.id} (${row.name}) active=true → cache upserted`);
169
+ }
170
+
171
+ async reconcile(): Promise<void> {
172
+ const rows = await TokenModel.findActive("A");
173
+ this.dispatcher.replaceCache(rows);
174
+ this.lastReconcileAt = new Date();
175
+ }
176
+ }
@@ -0,0 +1,80 @@
1
+ /** One-time setup for public.tokens -> tokens_changed NOTIFY triggers. */
2
+ import { getLogger } from "@logtape/logtape";
3
+ import { Client, type ClientConfig } from "pg";
4
+
5
+ const logger = getLogger(["qgrid", "trigger-setup"]);
6
+
7
+ const CONNECTION_TIMEOUT_MS = 5_000;
8
+ const SETUP_LOCK_TIMEOUT_MS = 3_000;
9
+ const SETUP_STATEMENT_TIMEOUT_MS = 10_000;
10
+
11
+ const ADVISORY_LOCK_CLASS_ID = 717;
12
+ const ADVISORY_LOCK_OBJECT_ID = 44900;
13
+
14
+ const SETUP_SQL = `
15
+ CREATE OR REPLACE FUNCTION public.tokens_notify() RETURNS trigger
16
+ LANGUAGE plpgsql
17
+ SET search_path = pg_catalog, public
18
+ AS $func$
19
+ BEGIN
20
+ PERFORM pg_notify('tokens_changed', json_build_object(
21
+ 'op', TG_OP,
22
+ 'id', COALESCE(NEW.id, OLD.id)
23
+ )::text);
24
+ RETURN COALESCE(NEW, OLD);
25
+ END;
26
+ $func$;
27
+
28
+ DROP TRIGGER IF EXISTS tokens_changed_trigger ON public.tokens;
29
+ DROP TRIGGER IF EXISTS tokens_changed ON public.tokens;
30
+
31
+ CREATE OR REPLACE TRIGGER tokens_changed_ins_del
32
+ AFTER INSERT OR DELETE ON public.tokens
33
+ FOR EACH ROW EXECUTE FUNCTION public.tokens_notify();
34
+
35
+ CREATE OR REPLACE TRIGGER tokens_changed_upd
36
+ AFTER UPDATE ON public.tokens
37
+ FOR EACH ROW
38
+ WHEN (
39
+ OLD.active IS DISTINCT FROM NEW.active OR
40
+ OLD.token IS DISTINCT FROM NEW.token OR
41
+ OLD.refresh_token IS DISTINCT FROM NEW.refresh_token OR
42
+ OLD.expires_at IS DISTINCT FROM NEW.expires_at OR
43
+ OLD.name IS DISTINCT FROM NEW.name
44
+ )
45
+ EXECUTE FUNCTION public.tokens_notify();
46
+ `;
47
+
48
+ export async function ensureTokensTrigger(connConfig: ClientConfig): Promise<void> {
49
+ const client = new Client({
50
+ ...connConfig,
51
+ connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
52
+ application_name: "qgrid-trigger-setup",
53
+ });
54
+
55
+ let lockAcquired = false;
56
+ try {
57
+ await client.connect();
58
+ await client.query("SET search_path TO public");
59
+ await client.query(`SET lock_timeout = '${SETUP_LOCK_TIMEOUT_MS}ms'`);
60
+ await client.query(`SET statement_timeout = '${SETUP_STATEMENT_TIMEOUT_MS}ms'`);
61
+ await client.query("SELECT pg_advisory_lock($1, $2)", [
62
+ ADVISORY_LOCK_CLASS_ID,
63
+ ADVISORY_LOCK_OBJECT_ID,
64
+ ]);
65
+ lockAcquired = true;
66
+
67
+ await client.query(SETUP_SQL);
68
+ logger.info("trigger ensured (public.tokens_notify + tokens_changed_* triggers)");
69
+ } finally {
70
+ if (lockAcquired) {
71
+ await client
72
+ .query("SELECT pg_advisory_unlock($1, $2)", [
73
+ ADVISORY_LOCK_CLASS_ID,
74
+ ADVISORY_LOCK_OBJECT_ID,
75
+ ])
76
+ .catch(() => {});
77
+ }
78
+ await client.end().catch(() => {});
79
+ }
80
+ }
@@ -97,7 +97,9 @@
97
97
  "duration_ms",
98
98
  "cost_usd"
99
99
  ],
100
- "cone": { "note": "리스트 조회용 Preview 서브셋. TEXT 컬럼(user_prompt, system_prompt, response) 제외" }
100
+ "cone": {
101
+ "note": "리스트 조회용 Preview 서브셋. TEXT 컬럼(user_prompt, system_prompt, response) 제외"
102
+ }
101
103
  }
102
104
  },
103
105
  "enums": {
@@ -3,13 +3,13 @@
3
3
  * 직접 수정하지 마세요.
4
4
  */
5
5
 
6
- import { PuriWrapper, DatabaseSchemaExtend, PuriLoaderQueries } from "sonamu";
6
+ import { type PuriWrapper, type DatabaseSchemaExtend, type PuriLoaderQueries } from "sonamu";
7
7
 
8
8
  import {
9
- RequestLogSubsetKey,
10
- TokenSubsetKey,
11
- RequestLogBaseSchema,
12
- TokenBaseSchema,
9
+ type RequestLogSubsetKey,
10
+ type TokenSubsetKey,
11
+ type RequestLogBaseSchema,
12
+ type TokenBaseSchema,
13
13
  } from "./sonamu.generated";
14
14
 
15
15
  // SubsetQuery: RequestLog
@@ -1,4 +1,4 @@
1
- import type { z } from "zod";
1
+ import { type z } from "zod";
2
2
 
3
3
  import { TokenBaseListParams, TokenBaseSchema } from "../sonamu.generated";
4
4
 
@@ -11,7 +11,7 @@ function getCurrentLocale(): (typeof SUPPORTED_LOCALES)[number] {
11
11
  try {
12
12
  const ctx = Sonamu.getContext();
13
13
  return (ctx.locale as (typeof SUPPORTED_LOCALES)[number]) ?? DEFAULT_LOCALE;
14
- } catch (_) {
14
+ } catch {
15
15
  return DEFAULT_LOCALE;
16
16
  }
17
17
  }
@@ -1,4 +1,4 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.createTable("request_logs", (table) => {
@@ -1,4 +1,4 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.createTable("tokens", (table) => {
@@ -1,4 +1,4 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("tokens", (table) => {
@@ -1,4 +1,4 @@
1
- import { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.raw(
@@ -1,4 +1,4 @@
1
- import { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("request_logs", (table) => {
@@ -1,4 +1,4 @@
1
- import { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("request_logs", (table) => {
@@ -1,4 +1,4 @@
1
- import { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("request_logs", (table) => {
@@ -1,4 +1,4 @@
1
- import { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
2
 
3
3
  export async function up(knex: Knex): Promise<void> {
4
4
  await knex.schema.alterTable("request_logs", (table) => {