@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.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/bundle/dist/application/qgrid/expired-token-reminder.js +9 -8
- package/bundle/dist/application/qgrid/qgrid.frame.js +8 -14
- package/bundle/dist/application/qgrid/token-death.js +12 -31
- package/bundle/dist/application/qgrid/token-trigger-setup.js +2 -1
- package/bundle/dist/application/sonamu.generated.js +3 -1
- package/bundle/dist/application/sonamu.generated.sso.js +2 -1
- package/bundle/dist/application/token/token.model.js +66 -47
- package/bundle/dist/application/token/token.types.js +2 -1
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260901163614_alter_tokens_add1_alter1.js +15 -0
- package/bundle/dist/startup-migrations.js +42 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +13 -3
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +3 -2
- package/bundle/dist/utils/providers/anthropic/claude-session.js +1 -1
- package/bundle/dist/utils/providers/common/model-cost.js +9 -11
- package/bundle/dist/utils/providers/openai/openai-refresh.js +6 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +8 -6
- package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -8
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +22 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
- package/bundle/src/application/qgrid/token-death.test.ts +62 -58
- package/bundle/src/application/qgrid/token-death.ts +15 -39
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +1 -0
- package/bundle/src/application/sonamu.generated.http +0 -27
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +3 -0
- package/bundle/src/application/token/token.entity.json +7 -0
- package/bundle/src/application/token/token.model.test.ts +112 -37
- package/bundle/src/application/token/token.model.ts +76 -48
- package/bundle/src/application/token/token.types.ts +1 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260901163614_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/startup-migrations.test.ts +116 -3
- package/bundle/src/startup-migrations.ts +70 -3
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +25 -1
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +23 -4
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +7 -1
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +3 -1
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +14 -10
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -19
- package/bundle/src/utils/providers/common/model-cost.ts +19 -16
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +16 -7
- package/bundle/src/utils/providers/openai/openai-refresh.ts +2 -2
- package/bundle/web-dist/client/assets/{index-D208L33c.js → index-DiioTXCk.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-noN5Aka6.js → logs-Cmh7iNUl.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-Ber13xcA.js → monit-BxqRbzYv.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-3_xetpSK.js → routes-DrNdqoD1.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-Da1Eq5_R.js → services.generated-C6jfEwfK.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-C5sHCg8X.js → settings-ROuRMHwU.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BhTgGnkc.js → show-Ct97ts6E.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-R7obwdAV.js → tokens-Cw2eWi9I.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-CRAqRZXX.js → use-oauth-login-flow-D4-P9yOJ.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CZ57EKA1.js → use-update-token-mutation-BIvO70-M.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-V1S8gqMS.js → lazyRouteComponent-C_k4R5n6.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-2Tkqcncq.js → logs-lBOU5eUP.js} +2 -2
- package/bundle/web-dist/server/assets/{monit-DrJ15xJL.js → monit-BW2X1D23.js} +2 -2
- package/bundle/web-dist/server/assets/{plus-DCai02Na.js → plus-DilmHifT.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-BFiG5Daf.js → react-dom-CHRRsKwb.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-Ct3I9263.js → routes-CxMHXfdH.js} +5 -5
- package/bundle/web-dist/server/assets/{services.generated-y7xVOTCo.js → services.generated-buX3cJzR.js} +1 -9
- package/bundle/web-dist/server/assets/{settings-JznSjwGQ.js → settings-CFoXQaAg.js} +3 -3
- package/bundle/web-dist/server/assets/{show-D8kuAFbc.js → show-CcZJO6WF.js} +2 -2
- package/bundle/web-dist/server/assets/{tokens-PR42jF1J.js → tokens-C32lycTe.js} +6 -5
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-C-33Z5HA.js → use-oauth-login-flow-DCjxIa80.js} +1 -1
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BNaRPVaX.js → use-update-token-mutation-CqK8i8Fa.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +46 -23
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +3 -3
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +13 -9
- package/skills/qgrid/references/cli-env-and-server-boot.md +4 -2
- package/skills/qgrid/references/decision-rationale.md +2 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- 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 {
|
|
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.
|
|
184
|
+
describe("TokenModel.findReauthRequired", () => {
|
|
181
185
|
afterEach(() => {
|
|
182
186
|
vi.restoreAllMocks();
|
|
183
187
|
});
|
|
184
188
|
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
const raw = mockKnex([{ rowCount: 1 }]);
|
|
204
|
+
await TokenModel.findReauthRequired("A");
|
|
196
205
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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("
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
270
|
+
* 인증 사망을 한 번만 기록하고, 다른 활성 토큰이 있으면 라우팅에서도 제외한다.
|
|
271
|
+
* 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
|
|
272
|
+
* 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
|
|
263
273
|
*/
|
|
264
|
-
async
|
|
274
|
+
async markReauthRequired(
|
|
265
275
|
id: number,
|
|
266
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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"] })
|
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
|
12
|
-
|
|
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
|
}
|