@cartanova/qgrid-cli 2.8.0 → 2.9.1

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 (79) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/qgrid/expired-token-reminder.js +9 -8
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +8 -14
  5. package/bundle/dist/application/qgrid/token-death.js +12 -31
  6. package/bundle/dist/application/qgrid/token-trigger-setup.js +2 -1
  7. package/bundle/dist/application/sonamu.generated.js +3 -1
  8. package/bundle/dist/application/sonamu.generated.sso.js +2 -1
  9. package/bundle/dist/application/token/token.model.js +66 -47
  10. package/bundle/dist/application/token/token.types.js +2 -1
  11. package/bundle/dist/i18n/sd.generated.js +2 -1
  12. package/bundle/dist/migrations/20260901163614_alter_tokens_add1_alter1.js +15 -0
  13. package/bundle/dist/startup-migrations.js +42 -6
  14. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +13 -3
  15. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +3 -2
  16. package/bundle/dist/utils/providers/anthropic/claude-session.js +1 -1
  17. package/bundle/dist/utils/providers/common/model-cost.js +9 -11
  18. package/bundle/dist/utils/providers/openai/openai-refresh.js +6 -4
  19. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +8 -6
  20. package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -8
  21. package/bundle/src/application/qgrid/qgrid.frame.test.ts +22 -0
  22. package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
  23. package/bundle/src/application/qgrid/token-death.test.ts +62 -58
  24. package/bundle/src/application/qgrid/token-death.ts +15 -39
  25. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  26. package/bundle/src/application/qgrid/token-trigger-setup.ts +1 -0
  27. package/bundle/src/application/sonamu.generated.http +0 -27
  28. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  29. package/bundle/src/application/sonamu.generated.ts +3 -0
  30. package/bundle/src/application/token/token.entity.json +7 -0
  31. package/bundle/src/application/token/token.model.test.ts +112 -37
  32. package/bundle/src/application/token/token.model.ts +76 -48
  33. package/bundle/src/application/token/token.types.ts +1 -0
  34. package/bundle/src/i18n/sd.generated.ts +1 -0
  35. package/bundle/src/migrations/20260901163614_alter_tokens_add1_alter1.ts +15 -0
  36. package/bundle/src/startup-migrations.test.ts +116 -3
  37. package/bundle/src/startup-migrations.ts +70 -3
  38. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +25 -1
  39. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +23 -4
  40. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +7 -1
  41. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +3 -1
  42. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +14 -10
  43. package/bundle/src/utils/providers/common/model-cost.test.ts +19 -19
  44. package/bundle/src/utils/providers/common/model-cost.ts +19 -16
  45. package/bundle/src/utils/providers/openai/openai-refresh.test.ts +16 -7
  46. package/bundle/src/utils/providers/openai/openai-refresh.ts +2 -2
  47. package/bundle/web-dist/client/assets/{index-D208L33c.js → index-DiioTXCk.js} +4 -4
  48. package/bundle/web-dist/client/assets/{logs-noN5Aka6.js → logs-Cmh7iNUl.js} +1 -1
  49. package/bundle/web-dist/client/assets/{monit-Ber13xcA.js → monit-BxqRbzYv.js} +1 -1
  50. package/bundle/web-dist/client/assets/{routes-3_xetpSK.js → routes-DrNdqoD1.js} +2 -2
  51. package/bundle/web-dist/client/assets/{services.generated-Da1Eq5_R.js → services.generated-C6jfEwfK.js} +1 -1
  52. package/bundle/web-dist/client/assets/{settings-C5sHCg8X.js → settings-ROuRMHwU.js} +1 -1
  53. package/bundle/web-dist/client/assets/{show-BhTgGnkc.js → show-Ct97ts6E.js} +1 -1
  54. package/bundle/web-dist/client/assets/{tokens-R7obwdAV.js → tokens-Cw2eWi9I.js} +1 -1
  55. package/bundle/web-dist/client/assets/{use-oauth-login-flow-CRAqRZXX.js → use-oauth-login-flow-D4-P9yOJ.js} +1 -1
  56. package/bundle/web-dist/client/assets/{use-update-token-mutation-CZ57EKA1.js → use-update-token-mutation-BIvO70-M.js} +1 -1
  57. package/bundle/web-dist/client/index.html +2 -2
  58. package/bundle/web-dist/server/assets/{lazyRouteComponent-V1S8gqMS.js → lazyRouteComponent-C_k4R5n6.js} +2 -2
  59. package/bundle/web-dist/server/assets/{logs-2Tkqcncq.js → logs-lBOU5eUP.js} +2 -2
  60. package/bundle/web-dist/server/assets/{monit-DrJ15xJL.js → monit-BW2X1D23.js} +2 -2
  61. package/bundle/web-dist/server/assets/{plus-DCai02Na.js → plus-DilmHifT.js} +1 -1
  62. package/bundle/web-dist/server/assets/{react-dom-BFiG5Daf.js → react-dom-CHRRsKwb.js} +1 -1
  63. package/bundle/web-dist/server/assets/{routes-Ct3I9263.js → routes-CxMHXfdH.js} +5 -5
  64. package/bundle/web-dist/server/assets/{services.generated-y7xVOTCo.js → services.generated-buX3cJzR.js} +1 -9
  65. package/bundle/web-dist/server/assets/{settings-JznSjwGQ.js → settings-CFoXQaAg.js} +3 -3
  66. package/bundle/web-dist/server/assets/{show-D8kuAFbc.js → show-CcZJO6WF.js} +2 -2
  67. package/bundle/web-dist/server/assets/{tokens-PR42jF1J.js → tokens-C32lycTe.js} +6 -5
  68. package/bundle/web-dist/server/assets/{use-oauth-login-flow-C-33Z5HA.js → use-oauth-login-flow-DCjxIa80.js} +1 -1
  69. package/bundle/web-dist/server/assets/{use-update-token-mutation-BNaRPVaX.js → use-update-token-mutation-CqK8i8Fa.js} +1 -1
  70. package/bundle/web-dist/server/entry-server.generated.js +46 -23
  71. package/package.json +1 -1
  72. package/skills/qgrid/SKILL.md +1 -1
  73. package/skills/qgrid/references/ai-sdk-provider-contract.md +3 -3
  74. package/skills/qgrid/references/anthropic-claude-code-runtime.md +13 -9
  75. package/skills/qgrid/references/cli-env-and-server-boot.md +4 -2
  76. package/skills/qgrid/references/decision-rationale.md +2 -1
  77. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  78. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  79. package/skills/qgrid/references/token-auth-quota-lifecycle.md +17 -13
@@ -14,6 +14,12 @@
14
14
  },
15
15
  { "name": "name", "desc": "토큰 이름", "type": "string" },
16
16
  { "name": "active", "type": "boolean", "desc": "활성 여부", "dbDefault": "true" },
17
+ {
18
+ "name": "reauth_required",
19
+ "type": "boolean",
20
+ "desc": "인증 만료로 재로그인이 필요한지 여부",
21
+ "dbDefault": "false"
22
+ },
17
23
  { "name": "ord", "type": "integer", "desc": "정렬 순서", "dbDefault": "0" },
18
24
  {
19
25
  "name": "quota_threshold",
@@ -50,6 +56,7 @@
50
56
  "credentials",
51
57
  "name",
52
58
  "active",
59
+ "reauth_required",
53
60
  "ord",
54
61
  "quota_threshold",
55
62
  "weight",
@@ -1,4 +1,7 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { Sonamu } from "sonamu";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  import { TokenModel } from "./token.model";
4
7
 
@@ -122,6 +125,7 @@ describe("TokenModel.replaceByAccount", () => {
122
125
  id: 1,
123
126
  created_at: new Date(),
124
127
  active: true,
128
+ reauth_required: false,
125
129
  ord: 0,
126
130
  quota_threshold: 80,
127
131
  weight: 1,
@@ -177,51 +181,122 @@ describe("TokenModel.findActiveByProviderAndName", () => {
177
181
  });
178
182
  });
179
183
 
180
- describe("TokenModel.deactivateIfActive", () => {
184
+ describe("TokenModel.findReauthRequired", () => {
181
185
  afterEach(() => {
182
186
  vi.restoreAllMocks();
183
187
  });
184
188
 
185
- function mockKnex(responses: unknown[]) {
186
- const raw = vi.fn();
187
- for (const response of responses) raw.mockResolvedValueOnce(response);
188
- vi.spyOn(TokenModel as unknown as { getPuri: (mode: "w") => unknown }, "getPuri").mockReturnValue(
189
- { knex: { raw } } as unknown as MockPuri,
190
- );
191
- return raw;
192
- }
189
+ it("queries auth-dead tokens instead of every inactive token", async () => {
190
+ const where = vi.fn();
191
+ const orderBy = vi.fn();
192
+ const qb = { where, orderBy };
193
+ vi.spyOn(
194
+ TokenModel as unknown as { getSubsetQueries: (subset: "A") => { qb: typeof qb } },
195
+ "getSubsetQueries",
196
+ ).mockReturnValue({ qb });
197
+ vi.spyOn(
198
+ TokenModel as unknown as {
199
+ executeSubsetQuery: (input: unknown) => Promise<{ rows: unknown[] }>;
200
+ },
201
+ "executeSubsetQuery",
202
+ ).mockResolvedValue({ rows: [] });
193
203
 
194
- it("deactivates in one statement that carries the last-token guard", async () => {
195
- const raw = mockKnex([{ rowCount: 1 }]);
204
+ await TokenModel.findReauthRequired("A");
196
205
 
197
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
198
- deactivated: true,
199
- keptAsLastActive: false,
200
- });
201
- expect(raw).toHaveBeenCalledTimes(1);
202
- const sql = raw.mock.calls[0]![0] as string;
203
- expect(sql).toContain("UPDATE tokens SET active = false");
204
- expect(sql).toContain("count(*)");
206
+ expect(where).toHaveBeenCalledWith("tokens.reauth_required", true);
207
+ expect(where).not.toHaveBeenCalledWith("tokens.active", false);
205
208
  });
209
+ });
206
210
 
207
- it("returns false without a diagnostic read when the token is already inactive", async () => {
208
- const raw = mockKnex([{ rowCount: 0 }, { rows: [] }]);
209
-
210
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
211
- deactivated: false,
212
- keptAsLastActive: false,
213
- });
214
- expect(raw).toHaveBeenCalledTimes(2);
211
+ describe("TokenModel.markReauthRequired", () => {
212
+ beforeAll(async () => {
213
+ await Sonamu.initForTesting();
215
214
  });
216
215
 
217
- it("refuses to deactivate the provider's last active token", async () => {
218
- const raw = mockKnex([{ rowCount: 0 }, { rows: [{ provider: "anthropic" }] }]);
219
-
220
- // 마지막 활성 토큰은 살려두되, 호출부가 systemic 실패로 알릴 수 있게 구분해 알린다.
221
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
222
- deactivated: false,
223
- keptAsLastActive: true,
224
- });
225
- expect(raw).toHaveBeenCalledTimes(2);
216
+ it("serializes provider deaths, preserves one active token, and rejects stale credentials", async () => {
217
+ const suffix = randomUUID();
218
+ const tokens = [
219
+ {
220
+ ...baseToken,
221
+ name: `auth-state-a-${suffix}`,
222
+ credentials: { ...baseToken.credentials, accountUuid: `account-a-${suffix}` },
223
+ },
224
+ {
225
+ ...baseToken,
226
+ name: `auth-state-b-${suffix}`,
227
+ credentials: { ...baseToken.credentials, accountUuid: `account-b-${suffix}` },
228
+ },
229
+ ];
230
+ const ids = await TokenModel.save(tokens);
231
+
232
+ try {
233
+ await expect(
234
+ TokenModel.markReauthRequired(ids[0]!, {
235
+ ...tokens[0]!.credentials,
236
+ accessToken: "stale-access-token",
237
+ }),
238
+ ).resolves.toEqual({
239
+ marked: false,
240
+ keptAsLastActive: false,
241
+ staleCredentials: true,
242
+ });
243
+
244
+ const results = await Promise.all([
245
+ TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
246
+ TokenModel.markReauthRequired(ids[1]!, tokens[1]!.credentials),
247
+ ]);
248
+ expect(results.every((result) => result.marked)).toBe(true);
249
+
250
+ const { rows } = await TokenModel.findMany("A", { id: ids, num: 2 });
251
+ expect(rows.every((row) => row.reauth_required)).toBe(true);
252
+ expect(rows.filter((row) => row.active)).toHaveLength(1);
253
+
254
+ await expect(
255
+ TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
256
+ ).resolves.toEqual({
257
+ marked: false,
258
+ keptAsLastActive: false,
259
+ staleCredentials: false,
260
+ });
261
+
262
+ const inactive = rows.find((row) => !row.active)!;
263
+ const refreshedCredentials = {
264
+ ...inactive.credentials,
265
+ accessToken: "refreshed-access-token",
266
+ refreshToken: "refreshed-refresh-token",
267
+ };
268
+ await TokenModel.save([
269
+ {
270
+ id: inactive.id,
271
+ provider: inactive.provider,
272
+ credentials: refreshedCredentials,
273
+ name: inactive.name,
274
+ reauth_required: false,
275
+ },
276
+ ]);
277
+ await expect(
278
+ TokenModel.markReauthRequired(inactive.id, inactive.credentials),
279
+ ).resolves.toEqual({
280
+ marked: false,
281
+ keptAsLastActive: false,
282
+ staleCredentials: true,
283
+ });
284
+
285
+ await expect(TokenModel.toggleActive(inactive.id)).resolves.toEqual({
286
+ active: true,
287
+ reauthRequired: false,
288
+ });
289
+ await TokenModel.toggleActive(inactive.id);
290
+ await Promise.all([
291
+ TokenModel.toggleActive(inactive.id),
292
+ TokenModel.markReauthRequired(inactive.id, refreshedCredentials),
293
+ ]);
294
+ await expect(TokenModel.findById("A", inactive.id)).resolves.toMatchObject({
295
+ active: false,
296
+ reauth_required: true,
297
+ });
298
+ } finally {
299
+ await TokenModel.del(ids);
300
+ }
226
301
  });
227
302
  });
@@ -1,3 +1,5 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
1
3
  import { getLogger } from "@logtape/logtape";
2
4
  import {
3
5
  api,
@@ -12,12 +14,13 @@ import {
12
14
  import { SD } from "../../i18n/sd.generated";
13
15
  import { type TokenSubsetKey, type TokenSubsetMapping } from "../sonamu.generated";
14
16
  import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso";
15
- import { type TokenListParams, type TokenSaveParams } from "./token.types";
17
+ import { type TokenCredentials, type TokenListParams, type TokenSaveParams } from "./token.types";
16
18
 
17
19
  const logger = getLogger(["qgrid", "token"]);
18
20
 
19
21
  const DEFAULT_QUOTA_THRESHOLD = 80;
20
22
  const DEFAULT_WEIGHT = 1;
23
+ const TOKEN_AUTH_DEATH_LOCK_CLASS_ID = 719;
21
24
 
22
25
  export type TokenUpdateFields = {
23
26
  name?: string;
@@ -167,13 +170,10 @@ class TokenModelClass extends BaseModelClass<
167
170
  return result.rows[0];
168
171
  }
169
172
 
170
- /**
171
- * 라우팅에서 빠진 토큰들. 세션 만료로 자동 비활성화된 것과 수동으로 끈 것이 섞여 있다 —
172
- * 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
173
- */
174
- async findInactive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
173
+ /** 실제 인증 만료가 확인돼 재로그인이 필요한 토큰들. 수동 비활성화는 제외한다. */
174
+ async findReauthRequired<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
175
175
  const { qb } = this.getSubsetQueries(subset);
176
- qb.where("tokens.active", false);
176
+ qb.where("tokens.reauth_required", true);
177
177
  qb.orderBy("tokens.name", "asc");
178
178
  const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
179
179
  const result = await this.executeSubsetQuery({
@@ -200,7 +200,6 @@ class TokenModelClass extends BaseModelClass<
200
200
  return result.rows;
201
201
  }
202
202
 
203
- @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
204
203
  async save(spa: TokenSaveParams[]): Promise<number[]> {
205
204
  const wdb = this.getPuri("w");
206
205
  spa.forEach((sp) => {
@@ -216,15 +215,26 @@ class TokenModelClass extends BaseModelClass<
216
215
  return wdb.transaction((trx) => trx.table("tokens").where("id", id).update(fields));
217
216
  }
218
217
 
219
- /**
220
- * 세션 만료 토큰을 라우팅에서 제외한다. `active=true` 조건부 갱신이므로 공유 DB 를 쓰는
221
- * 여러 인스턴스가 동시에 같은 만료를 감지해도 true 를 받는 프로세스는 하나뿐이다
222
- * (호출부의 알림 발송 게이트).
223
- *
224
- * 단, 대상이 해당 provider 의 마지막 활성 토큰이면 갱신하지 않고 false 를 반환한다.
225
- * client_id 취소나 OAuth 계약 변경처럼 전 토큰이 동시에 실패하는 상황에서 풀이
226
- * 통째로 비는 것을 막는 안전판이다.
227
- */
218
+ async toggleActive(id: number): Promise<{ active: boolean; reauthRequired: boolean } | null> {
219
+ const wdb = this.getPuri("w");
220
+ return wdb.transaction(async (trx) => {
221
+ const token = await trx
222
+ .from("tokens")
223
+ .select({ active: "active", reauth_required: "reauth_required" })
224
+ .where("id", id)
225
+ .forUpdate()
226
+ .first();
227
+ if (!token) return null;
228
+ if (!token.active && token.reauth_required) {
229
+ return { active: false, reauthRequired: true };
230
+ }
231
+
232
+ const active = !token.active;
233
+ await trx.from("tokens").where("id", id).update({ active });
234
+ return { active, reauthRequired: false };
235
+ });
236
+ }
237
+
228
238
  /**
229
239
  * 같은 계정의 기존 토큰을 지우고 새로 저장한다(로그인·재로그인 공통 경로).
230
240
  *
@@ -257,43 +267,61 @@ class TokenModelClass extends BaseModelClass<
257
267
  }
258
268
 
259
269
  /**
260
- * @returns `deactivated` 는 이 호출이 실제로 비활성화를 수행했는지 — 호출부의 알림
261
- * 게이트다. `keptAsLastActive` 는 마지막 활성 토큰이라 거부한 경우로, 사망 자체는
262
- * 사실이지만 풀을 비우지 않으려고 살려둔 상태다(systemic 실패 신호).
270
+ * 인증 사망을 한 번만 기록하고, 다른 활성 토큰이 있으면 라우팅에서도 제외한다.
271
+ * 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
272
+ * 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
263
273
  */
264
- async deactivateIfActive(
274
+ async markReauthRequired(
265
275
  id: number,
266
- ): Promise<{ deactivated: boolean; keptAsLastActive: boolean }> {
276
+ expectedCredentials: TokenCredentials,
277
+ ): Promise<{ marked: boolean; keptAsLastActive: boolean; staleCredentials: boolean }> {
267
278
  const wdb = this.getPuri("w");
279
+ return wdb.transaction(async (trx) => {
280
+ const token = await trx
281
+ .from("tokens")
282
+ .select({
283
+ provider: "provider",
284
+ credentials: "credentials",
285
+ reauth_required: "reauth_required",
286
+ })
287
+ .where("id", id)
288
+ .forUpdate()
289
+ .first();
290
+ if (!token || token.reauth_required) {
291
+ return { marked: false, keptAsLastActive: false, staleCredentials: false };
292
+ }
293
+ if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
294
+ return { marked: false, keptAsLastActive: false, staleCredentials: true };
295
+ }
268
296
 
269
- // 활성 여부와 "마지막 남은 토큰인가"를 한 statement 안에서 판정한다. 두 조건을
270
- // 따로 읽고 나중에 갱신하면 그 사이에 다른 인스턴스가 같은 provider 의 토큰을
271
- // 비활성화해 풀이 통째로 빌 수 있다.
272
- const updated = await wdb.knex.raw<{ rowCount: number }>(
273
- `UPDATE tokens SET active = false
274
- WHERE id = ? AND active = true
275
- AND (SELECT count(*) FROM tokens peer
276
- WHERE peer.provider = tokens.provider AND peer.active) > 1`,
277
- [id],
278
- );
279
- if ((updated.rowCount ?? 0) > 0) return { deactivated: true, keptAsLastActive: false };
280
-
281
- // 갱신되지 않은 이유는 둘 중 하나다: 이미 비활성이거나(정상 경쟁 결과), 마지막
282
- // 활성 토큰이거나. 후자는 systemic 실패 신호라 남겨야 하므로 이때만 한 번 더 읽는다.
283
- const survivor = (
284
- await wdb.knex.raw<{ rows: { provider: string }[] }>(
285
- "SELECT provider FROM tokens WHERE id = ? AND active = true",
297
+ await trx.knex.raw("SELECT pg_advisory_xact_lock(?, hashtext(?))", [
298
+ TOKEN_AUTH_DEATH_LOCK_CLASS_ID,
299
+ token.provider,
300
+ ]);
301
+ const updated = await trx.knex.raw<{ rows: { active: boolean }[] }>(
302
+ `UPDATE tokens
303
+ SET reauth_required = true,
304
+ active = CASE
305
+ WHEN active AND (SELECT count(*) FROM tokens peer
306
+ WHERE peer.provider = tokens.provider AND peer.active) > 1
307
+ THEN false
308
+ ELSE active
309
+ END
310
+ WHERE id = ? AND reauth_required = false
311
+ RETURNING active`,
286
312
  [id],
287
- )
288
- ).rows[0];
289
- if (survivor) {
290
- logger.error(
291
- `refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
292
- `systemic refresh failure suspected, keeping the pool non-empty`,
293
313
  );
294
- return { deactivated: false, keptAsLastActive: true };
295
- }
296
- return { deactivated: false, keptAsLastActive: false };
314
+ const marked = updated.rows[0];
315
+ if (!marked) return { marked: false, keptAsLastActive: false, staleCredentials: false };
316
+
317
+ if (marked.active) {
318
+ logger.error(
319
+ `refusing to auto-deactivate token ${id}: last active provider token — ` +
320
+ `systemic refresh failure suspected, keeping the pool non-empty`,
321
+ );
322
+ }
323
+ return { marked: true, keptAsLastActive: marked.active, staleCredentials: false };
324
+ });
297
325
  }
298
326
 
299
327
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
@@ -38,6 +38,7 @@ export const TokenSaveParams = TokenBaseSchema.partial({
38
38
  id: true,
39
39
  created_at: true,
40
40
  active: true,
41
+ reauth_required: true,
41
42
  ord: true,
42
43
  quota_threshold: true,
43
44
  weight: true,
@@ -317,6 +317,7 @@ const entityLabels = {
317
317
  "entity.Token.credentials": "인증 정보 (JSONB)",
318
318
  "entity.Token.name": "토큰 이름",
319
319
  "entity.Token.active": "활성 여부",
320
+ "entity.Token.reauth_required": "인증 만료로 재로그인이 필요한지 여부",
320
321
  "entity.Token.ord": "정렬 순서",
321
322
  "entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
322
323
  "entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("tokens", (table) => {
5
+ // add
6
+ table.boolean("reauth_required").notNullable().defaultTo(knex.raw("false"));
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("tokens", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("reauth_required");
14
+ });
15
+ }
@@ -1,4 +1,8 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
6
 
3
7
  const { latest } = vi.hoisted(() => ({ latest: vi.fn() }));
4
8
 
@@ -8,17 +12,126 @@ vi.mock("./application/token/token.model", () => ({
8
12
  },
9
13
  }));
10
14
 
11
- import { runRequiredMigrations } from "./startup-migrations";
15
+ import {
16
+ createStartupMigrationSource,
17
+ resolveStartupMigrationDirs,
18
+ runRequiredMigrations,
19
+ } from "./startup-migrations";
20
+
21
+ const tempRoots: string[] = [];
22
+
23
+ async function makeDirs() {
24
+ const root = await mkdtemp(path.join(os.tmpdir(), "qgrid-startup-migrations-"));
25
+ tempRoots.push(root);
26
+ const sourceDir = path.join(root, "src", "migrations");
27
+ const compiledDir = path.join(root, "dist", "migrations");
28
+ await mkdir(sourceDir, { recursive: true });
29
+ await mkdir(compiledDir, { recursive: true });
30
+ return { sourceDir, compiledDir };
31
+ }
32
+
33
+ afterEach(async () => {
34
+ vi.restoreAllMocks();
35
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
36
+ });
37
+
38
+ describe("resolveStartupMigrationDirs", () => {
39
+ it("패키지된 CLI(dist 실행)에서는 이름은 bundle/src, 모듈은 bundle/dist 를 가리킨다", () => {
40
+ expect(resolveStartupMigrationDirs("/opt/qgrid-cli/bundle/dist")).toEqual({
41
+ sourceDir: "/opt/qgrid-cli/bundle/src/migrations",
42
+ compiledDir: "/opt/qgrid-cli/bundle/dist/migrations",
43
+ });
44
+ });
45
+
46
+ it("소스 실행(src)에서는 두 경로가 같은 src/migrations 다", () => {
47
+ expect(resolveStartupMigrationDirs("/repo/packages/api/src")).toEqual({
48
+ sourceDir: "/repo/packages/api/src/migrations",
49
+ compiledDir: "/repo/packages/api/src/migrations",
50
+ });
51
+ });
52
+ });
53
+
54
+ describe("createStartupMigrationSource", () => {
55
+ it("knex_migrations 에 기록된 것과 같은 .ts 파일명을 정렬해 나열하고 그 외 파일은 무시한다", async () => {
56
+ const dirs = await makeDirs();
57
+ await writeFile(path.join(dirs.sourceDir, "20260901163614_alter_tokens_add1_alter1.ts"), "");
58
+ await writeFile(path.join(dirs.sourceDir, "20260820153531_alter_tokens_add1_alter1.ts"), "");
59
+ await writeFile(path.join(dirs.sourceDir, "20260820153531_alter_tokens_add1_alter1.d.ts"), "");
60
+ await writeFile(path.join(dirs.sourceDir, "README.md"), "");
61
+
62
+ const source = createStartupMigrationSource(dirs);
63
+ const names = await source.getMigrations([]);
64
+
65
+ expect(names).toEqual([
66
+ "20260820153531_alter_tokens_add1_alter1.ts",
67
+ "20260901163614_alter_tokens_add1_alter1.ts",
68
+ ]);
69
+ expect(source.getMigrationName("20260820153531_alter_tokens_add1_alter1.ts")).toBe(
70
+ "20260820153531_alter_tokens_add1_alter1.ts",
71
+ );
72
+ });
73
+
74
+ it("컴파일된 .js 가 있으면 .ts 대신 그것을 로드한다 (2.9.0 부팅 크래시 재발 방지)", async () => {
75
+ const dirs = await makeDirs();
76
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
77
+ await writeFile(path.join(dirs.sourceDir, name), 'throw new Error("source .ts must not load");\n');
78
+ await writeFile(
79
+ path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js")),
80
+ 'export async function up() { return "compiled"; }\nexport async function down() { return "down"; }\n',
81
+ );
82
+
83
+ const migration = await createStartupMigrationSource(dirs).getMigration(name);
84
+
85
+ await expect(migration.up({} as never)).resolves.toBe("compiled");
86
+ await expect(migration.down?.({} as never)).resolves.toBe("down");
87
+ });
88
+
89
+ it("컴파일 산출물이 없으면 소스 모듈로 fallback 한다 (ts loader 가 있는 소스 실행)", async () => {
90
+ const dirs = await makeDirs();
91
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
92
+ await writeFile(
93
+ path.join(dirs.sourceDir, name),
94
+ 'export async function up() { return "source"; }\n',
95
+ );
96
+
97
+ const migration = await createStartupMigrationSource(dirs).getMigration(name);
98
+
99
+ await expect(migration.up({} as never)).resolves.toBe("source");
100
+ await expect(migration.down?.({} as never)).resolves.toBeUndefined();
101
+ });
102
+
103
+ it("up() 이 없는 모듈은 명확한 오류로 실패한다", async () => {
104
+ const dirs = await makeDirs();
105
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
106
+ await writeFile(path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js")), "export const x = 1;\n");
107
+
108
+ await expect(createStartupMigrationSource(dirs).getMigration(name)).rejects.toThrow(
109
+ /has no up\(\) export/,
110
+ );
111
+ });
112
+ });
12
113
 
13
114
  describe("required startup migrations", () => {
115
+ it("knex 기본 directory 대신 migrationSource 로 latest 를 실행한다", async () => {
116
+ const dirs = await makeDirs();
117
+ latest.mockResolvedValueOnce([3, ["20260901163614_alter_tokens_add1_alter1.ts"]]);
118
+
119
+ await runRequiredMigrations(dirs);
120
+
121
+ expect(latest).toHaveBeenCalledWith({ migrationSource: expect.any(Object) });
122
+ const call = latest.mock.calls.at(-1)?.[0] as { migrationSource: unknown; directory?: string };
123
+ expect(call.directory).toBeUndefined();
124
+ });
125
+
14
126
  it("terminates with a failure status when migration fails", async () => {
127
+ const dirs = await makeDirs();
15
128
  const failure = new Error("tokens.weight migration failed");
16
129
  const exit = vi.spyOn(process, "exit").mockImplementation(() => {
17
130
  throw failure;
18
131
  });
19
132
  latest.mockRejectedValueOnce(failure);
20
133
 
21
- await expect(runRequiredMigrations()).rejects.toBe(failure);
134
+ await expect(runRequiredMigrations(dirs)).rejects.toBe(failure);
22
135
 
23
136
  expect(exit).toHaveBeenCalledWith(1);
24
137
  });
@@ -1,15 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
1
3
  import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
2
5
 
3
6
  import { getLogger } from "@logtape/logtape";
7
+ import { type Knex } from "knex";
4
8
 
5
9
  import { TokenModel } from "./application/token/token.model";
6
10
 
7
- export async function runRequiredMigrations(): Promise<void> {
11
+ export type StartupMigrationDirs = {
12
+ /** Sonamu 가 migration 을 생성·기록하는 원본 디렉터리. `knex_migrations.name` 은 이 파일명(`.ts`)이다. */
13
+ sourceDir: string;
14
+ /** 빌드 산출물 디렉터리. dist 에서 실행 중이면 같은 이름의 `.js` 가 여기 있다. */
15
+ compiledDir: string;
16
+ };
17
+
18
+ // import.meta.dirname 은 소스 실행(ts loader)에서는 `src/`, 패키지된 CLI 에서는 `bundle/dist/` 다.
19
+ // 두 경우 모두 `../src/migrations` 가 이름의 정본이고 `./migrations` 가 컴파일 산출물 위치다.
20
+ // 소스 실행에서는 둘이 같은 디렉터리라 컴파일 파일이 없고, `.ts` 를 loader 가 직접 읽는다.
21
+ export function resolveStartupMigrationDirs(
22
+ moduleDir: string = import.meta.dirname,
23
+ ): StartupMigrationDirs {
24
+ return {
25
+ sourceDir: path.join(moduleDir, "../src/migrations"),
26
+ compiledDir: path.join(moduleDir, "migrations"),
27
+ };
28
+ }
29
+
30
+ // Sonamu 가 만드는 파일명 규칙: `YYYYMMDDHHmmss_<title>.ts`. 타입 선언이나 README 는 제외한다.
31
+ const MIGRATION_FILE_RE = /^\d{14}_.+\.ts$/;
32
+
33
+ type MigrationModule = {
34
+ up?: (knex: Knex) => Promise<unknown>;
35
+ down?: (knex: Knex) => Promise<unknown>;
36
+ };
37
+
38
+ /**
39
+ * 패키지된 CLI 는 `bundle/dist` 에서 실행되는데, knex 기본 FsMigrations 에 `../src/migrations` 를
40
+ * 넘기면 pending migration 을 `.ts` 로 import 하다 "Unknown file extension .ts" 로 죽는다(2.9.0 사고).
41
+ * 반대로 디렉터리를 `dist/migrations` 로 바꾸면 `knex_migrations` 에 `.ts` 이름으로 기록된 과거
42
+ * migration 이 전부 pending 으로 보인다. 그래서 이름은 `src/migrations` 의 `.ts` 파일명을 그대로
43
+ * 쓰고, 모듈만 컴파일된 `.js` 가 있으면 그것을 로드한다.
44
+ */
45
+ export function createStartupMigrationSource(
46
+ dirs: StartupMigrationDirs,
47
+ ): Knex.MigrationSource<string> {
48
+ return {
49
+ async getMigrations() {
50
+ const entries = await readdir(dirs.sourceDir);
51
+ return entries
52
+ .filter((name) => MIGRATION_FILE_RE.test(name) && !name.endsWith(".d.ts"))
53
+ .sort();
54
+ },
55
+ getMigrationName(name) {
56
+ return name;
57
+ },
58
+ async getMigration(name) {
59
+ const compiled = path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js"));
60
+ const source = path.join(dirs.sourceDir, name);
61
+ const target = existsSync(compiled) ? compiled : source;
62
+ const module = (await import(pathToFileURL(target).href)) as MigrationModule;
63
+ if (typeof module.up !== "function") {
64
+ throw new Error(`migration ${name} has no up() export (${target})`);
65
+ }
66
+ return { up: module.up, down: module.down ?? (async () => undefined) };
67
+ },
68
+ };
69
+ }
70
+
71
+ export async function runRequiredMigrations(
72
+ dirs: StartupMigrationDirs = resolveStartupMigrationDirs(),
73
+ ): Promise<void> {
8
74
  const log = getLogger(["qgrid", "startup"]);
9
75
  try {
10
76
  const knex = TokenModel.getDB("w");
11
- const migrationsDir = path.join(import.meta.dirname, "../src/migrations");
12
- const [batch, migrations] = await knex.migrate.latest({ directory: migrationsDir });
77
+ const [batch, migrations] = await knex.migrate.latest({
78
+ migrationSource: createStartupMigrationSource(dirs),
79
+ });
13
80
  if (migrations.length > 0) {
14
81
  log.info(`migration: ${migrations.length} applied (batch ${batch})`);
15
82
  }