@cartanova/qgrid-cli 2.10.0 → 2.10.2

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 (77) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/qgrid/conv-routing.js +4 -14
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +10 -5
  5. package/bundle/dist/application/request-log/request-log.model.js +14 -12
  6. package/bundle/dist/application/request-log-step/request-log-step.model.js +7 -11
  7. package/bundle/dist/application/setting/setting.model.js +13 -27
  8. package/bundle/dist/application/token/token.model.js +52 -47
  9. package/bundle/dist/server-error-handler.js +1 -1
  10. package/bundle/dist/sonamu.config.js +3 -15
  11. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +4 -2
  12. package/bundle/dist/utils/providers/common/model-cost.js +10 -3
  13. package/bundle/src/application/qgrid/conv-routing.test.ts +0 -32
  14. package/bundle/src/application/qgrid/conv-routing.ts +7 -26
  15. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +65 -121
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +12 -6
  17. package/bundle/src/application/qgrid/qgrid.frame.test.ts +10 -24
  18. package/bundle/src/application/request-log/request-log.model.test.ts +37 -2
  19. package/bundle/src/application/request-log/request-log.model.ts +14 -7
  20. package/bundle/src/application/request-log-step/request-log-step.model.ts +3 -15
  21. package/bundle/src/application/setting/setting.constant.test.ts +1 -13
  22. package/bundle/src/application/setting/setting.model.ts +10 -26
  23. package/bundle/src/application/setting/setting.persistence.test.ts +27 -0
  24. package/bundle/src/application/token/token.model.test.ts +86 -19
  25. package/bundle/src/application/token/token.model.ts +59 -44
  26. package/bundle/src/server-error-handler.ts +1 -2
  27. package/bundle/src/sonamu.config.ts +4 -31
  28. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +6 -1
  29. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +5 -2
  30. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +1 -26
  31. package/bundle/src/utils/providers/common/model-cost.test.ts +22 -1
  32. package/bundle/src/utils/providers/common/model-cost.ts +22 -5
  33. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -11
  34. package/bundle/web-dist/client/assets/index-DtRTSTes.js +106 -0
  35. package/bundle/web-dist/client/assets/logs-BhVYDEzC.js +1 -0
  36. package/bundle/web-dist/client/assets/monit-BxN4JWqK.js +1 -0
  37. package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
  38. package/bundle/web-dist/client/assets/{routes-C8Y6olae.js → routes-CBCqRp2o.js} +1 -1
  39. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +51 -0
  40. package/bundle/web-dist/client/assets/{settings-CdVqtDQ5.js → settings-hBTEC7Ki.js} +1 -1
  41. package/bundle/web-dist/client/assets/show-BzYft5N4.js +2 -0
  42. package/bundle/web-dist/client/assets/{tokens-CxXtdk9o.js → tokens-PMybtI8X.js} +1 -1
  43. package/bundle/web-dist/client/assets/use-oauth-login-flow-C51_d6-6.js +1 -0
  44. package/bundle/web-dist/client/assets/{use-update-token-mutation-Dkv_HG-C.js → use-update-token-mutation-D-Y8OySK.js} +1 -1
  45. package/bundle/web-dist/client/index.html +2 -2
  46. package/bundle/web-dist/server/assets/{lazyRouteComponent-DyizoQKi.js → lazyRouteComponent-Bj1cPq6x.js} +2 -2
  47. package/bundle/web-dist/server/assets/{logs-DO8zBdBR.js → logs-BSk9lOHe.js} +31 -13
  48. package/bundle/web-dist/server/assets/{monit-COH4MckI.js → monit-D9W4ioUi.js} +5 -24
  49. package/bundle/web-dist/server/assets/{plus-C1D8z9o0.js → plus-CmvbWuDf.js} +1 -1
  50. package/bundle/web-dist/server/assets/{react-dom-PfWvArTX.js → react-dom-BjR1Iaz8.js} +1 -1
  51. package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
  52. package/bundle/web-dist/server/assets/{routes-Btja-741.js → routes-C7lB2Q85.js} +5 -5
  53. package/bundle/web-dist/server/assets/{services.generated-BsKOUSXl.js → services.generated-BM4X3Ecj.js} +102 -6
  54. package/bundle/web-dist/server/assets/{settings-DPcWGvy-.js → settings-BFVuldch.js} +4 -4
  55. package/bundle/web-dist/server/assets/{show-CMXbb3TJ.js → show-Cswh_tqs.js} +10 -9
  56. package/bundle/web-dist/server/assets/{tokens-DAUmTrgO.js → tokens-DiTUwUwr.js} +5 -5
  57. package/bundle/web-dist/server/assets/{use-oauth-login-flow-GsGwqefs.js → use-oauth-login-flow-DYKnG4A2.js} +32 -15
  58. package/bundle/web-dist/server/assets/{use-update-token-mutation-RujWERvj.js → use-update-token-mutation-DqGMopII.js} +1 -1
  59. package/bundle/web-dist/server/entry-server.generated.js +381 -22
  60. package/package.json +1 -1
  61. package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
  62. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -2
  63. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  64. package/skills/qgrid/references/sonamu-api-web-flow.md +3 -1
  65. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  66. package/skills/qgrid/references/verification-and-debugging.md +24 -13
  67. package/bundle/dist/utils/providers/common/token-picker.js +0 -14
  68. package/bundle/src/utils/providers/common/token-picker.ts +0 -18
  69. package/bundle/web-dist/client/assets/index-BfwrIBHs.js +0 -106
  70. package/bundle/web-dist/client/assets/logs-Brit-LqB.js +0 -1
  71. package/bundle/web-dist/client/assets/monit-DrYZkisv.js +0 -1
  72. package/bundle/web-dist/client/assets/services.generated-Bg2XkD-D.js +0 -51
  73. package/bundle/web-dist/client/assets/show-2B2X9MkO.js +0 -2
  74. package/bundle/web-dist/client/assets/use-oauth-login-flow-B9EqnwY9.js +0 -1
  75. /package/bundle/web-dist/client/assets/{plus-CknMNCP5.js → plus-B62CuqT8.js} +0 -0
  76. /package/bundle/web-dist/client/assets/{request-model-eygfdRv7.js → request-model-D4CZPmnG.js} +0 -0
  77. /package/bundle/web-dist/server/assets/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
@@ -48,8 +48,9 @@ export type InternalQueryInput = QueryInput & {
48
48
  export class QgridDispatcherClass {
49
49
  tokens = new Map<number, TokenSubsetA>();
50
50
 
51
- // TokenStats shape 보존용. 실제 provider 요청 카운팅은 각 dispatcher 로 이동했다.
52
- requestCounts = new Map<string, number>();
51
+ // 프로세스 시작 이후 완료된 provider generation 수. 실패/중간 시도는 제외하며,
52
+ // 후속 tool turn도 한 generation으로 센다. 동명 토큰은 provider별로 분리한다.
53
+ private requestCounts = new Map<string, number>();
53
54
 
54
55
  // sonamu.config onStart 에서 처리하는 변수
55
56
  subscriber: TokenSubscriber | null = null;
@@ -61,7 +62,7 @@ export class QgridDispatcherClass {
61
62
  * HTTP 리스닝은 dispatcher 준비보다 먼저 열리므로(sonamu.config onStart),
62
63
  * 그 사이 들어온 요청은 "잠시 후 되는" 상태와 "재시도해도 안 되는" 상태가 다르다.
63
64
  *
64
- * - `starting`: 워커 spawn 중. dev0 기준 25 워커 × 500ms 간격이라 1~2분 걸린다 → 재시도 가능
65
+ * - `starting`: provider 토큰·런타임 초기화 중 → 재시도 가능
65
66
  * - `ready`: 정상
66
67
  * - `failed`: start() 가 예외로 끝남 → 재시도해도 같은 결과
67
68
  */
@@ -84,8 +85,9 @@ export class QgridDispatcherClass {
84
85
  : new ServiceUnavailableException(SD("qgrid.dispatcherStarting")(label));
85
86
  }
86
87
 
87
- countOf(name: string): number {
88
- return this.requestCounts.get(name) ?? 0;
88
+ private recordCompleted(provider: string, tokenName: string): void {
89
+ const key = `${provider}\0${tokenName}`;
90
+ this.requestCounts.set(key, (this.requestCounts.get(key) ?? 0) + 1);
89
91
  }
90
92
 
91
93
  // TokenSubscriber 콜백 — 캐시 mutation
@@ -106,7 +108,7 @@ export class QgridDispatcherClass {
106
108
  token: maskToken(getAccessToken(r.credentials)),
107
109
  name: r.name,
108
110
  provider: r.provider,
109
- requests: this.countOf(r.name),
111
+ requests: this.requestCounts.get(`${r.provider}\0${r.name}`) ?? 0,
110
112
  }));
111
113
  }
112
114
 
@@ -143,6 +145,7 @@ export class QgridDispatcherClass {
143
145
  imageGeneration: input.imageGeneration,
144
146
  imageGenerationOptions: input.imageGenerationOptions,
145
147
  });
148
+ this.recordCompleted("openai", result.tokenName);
146
149
 
147
150
  // 이미지 요청은 cold-only(R8)라 재사용 좌표를 발급하지 않는다. 좌표를 실으면
148
151
  // sessionKey 소비자의 warm 좌표를 죽은 좌표로 덮어써 다음 텍스트 turn 이 cold 로 떨어진다.
@@ -175,6 +178,7 @@ export class QgridDispatcherClass {
175
178
  imageGeneration: input.imageGeneration,
176
179
  imageGenerationOptions: input.imageGenerationOptions,
177
180
  });
181
+ this.recordCompleted("anthropic", result.tokenName);
178
182
 
179
183
  return applyToolCallEmulation(toEmulationResult(result), input.tools, {
180
184
  threadCoord: issueConvContext(result.threadCoord, decision),
@@ -227,6 +231,7 @@ export class QgridDispatcherClass {
227
231
  onThreadId: cb.onThreadId,
228
232
  onTurnId: cb.onTurnId,
229
233
  onComplete: (turnResult) => {
234
+ this.recordCompleted("openai", turnResult.tokenName);
230
235
  cb.onComplete(
231
236
  applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
232
237
  threadCoord: issueConvContext(
@@ -276,6 +281,7 @@ export class QgridDispatcherClass {
276
281
  onDelta: emitDelta,
277
282
  onThreadId: cb.onThreadId,
278
283
  onComplete: (turnResult) => {
284
+ this.recordCompleted("anthropic", turnResult.tokenName);
279
285
  // 홀드백 잔여(닫는 펜스가 아니었던 tail)를 done 전에 마저 방출한다 —
280
286
  // 델타를 조립하는 클라이언트(EnvelopeStreamParser 등)의 무손실 보장.
281
287
  const rest = fenceStrip?.flush();
@@ -896,20 +896,29 @@ describe("QgridFrame.prepareStream", () => {
896
896
  });
897
897
 
898
898
  describe("QgridFrame raw lifecycle API", () => {
899
- it("treats createRun.modelName as the requested model", async () => {
899
+ it("maps the requested model and structured metadata through the external createRun API", async () => {
900
900
  requestLogCreateRunMock.mockReset().mockResolvedValue(81);
901
+ const jsonSchema = JSON.stringify({
902
+ type: "object",
903
+ properties: { answer: { type: "string" } },
904
+ required: ["answer"],
905
+ });
901
906
 
902
907
  await expect(
903
908
  QgridFrame.createRun({
904
909
  userPrompt: "hi",
905
910
  modelName: "google/gemini-3-flash",
906
911
  projectName: "external",
912
+ isStructured: true,
913
+ jsonSchema,
907
914
  }),
908
915
  ).resolves.toEqual({ requestLogId: 81 });
909
916
 
910
917
  expect(requestLogCreateRunMock).toHaveBeenCalledWith(
911
918
  expect.objectContaining({
912
919
  requested_model_name: "google/gemini-3-flash",
920
+ is_structured: true,
921
+ json_schema: jsonSchema,
913
922
  }),
914
923
  );
915
924
  expect(requestLogCreateRunMock).toHaveBeenCalledWith(
@@ -917,29 +926,6 @@ describe("QgridFrame raw lifecycle API", () => {
917
926
  );
918
927
  });
919
928
 
920
- it("passes optional structured metadata through the external createRun API", async () => {
921
- requestLogCreateRunMock.mockReset().mockResolvedValue(82);
922
- const jsonSchema = JSON.stringify({
923
- type: "object",
924
- properties: { answer: { type: "string" } },
925
- required: ["answer"],
926
- });
927
-
928
- await QgridFrame.createRun({
929
- userPrompt: "hi",
930
- modelName: "google/gemini-3.5-flash-lite",
931
- isStructured: true,
932
- jsonSchema,
933
- });
934
-
935
- expect(requestLogCreateRunMock).toHaveBeenCalledWith(
936
- expect.objectContaining({
937
- is_structured: true,
938
- json_schema: jsonSchema,
939
- }),
940
- );
941
- });
942
-
943
929
  it("passes optional response JSON validity through the external finishRun API", async () => {
944
930
  requestLogFinishRunMock.mockReset().mockResolvedValue(undefined);
945
931
 
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  import { Client, type ClientConfig } from "pg";
2
4
  import { Sonamu } from "sonamu";
3
5
  import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -613,7 +615,7 @@ describe("RequestLogModel cost provenance", () => {
613
615
  await RequestLogModel.totalCost();
614
616
 
615
617
  expect(sumChain.select).toHaveBeenCalledTimes(1);
616
- // 조건을 걸면 cost_source 가 인덱스에 없어 커버링 인덱스가 무효화된다.
618
+ // source 없는 행도 저장된 이미지 비용을 포함하고 driver 차액만 별도로 보정한다.
617
619
  expect(sumChain.whereRaw).not.toHaveBeenCalled();
618
620
  expect(legacyChain.whereRaw).toHaveBeenCalledWith(
619
621
  "request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL",
@@ -723,11 +725,44 @@ describe("RequestLogModel cost provenance", () => {
723
725
  });
724
726
  });
725
727
 
726
- describe("RequestLogModel PostgreSQL run lock", () => {
728
+ describe("RequestLogModel PostgreSQL", () => {
727
729
  beforeAll(async () => {
728
730
  await Sonamu.initForTesting();
729
731
  });
730
732
 
733
+ it("includes image costs once and filters both stored totals and legacy adjustments", async () => {
734
+ const projectName = `cost-${randomUUID()}`;
735
+ const ids: number[] = [];
736
+ try {
737
+ for (const [index, costs] of [
738
+ { cost_usd: 5_000, image_cost_usd: 10_000, cost_source: "provider" },
739
+ { cost_usd: 1_000, image_cost_usd: 20_000, cost_source: null },
740
+ { cost_usd: 900_000, image_cost_usd: 700_000, cost_source: null },
741
+ ].entries()) {
742
+ const id = await RequestLogModel.createRun({
743
+ user_prompt: "cost regression",
744
+ project_name: index < 2 ? projectName : `${projectName}-other`,
745
+ });
746
+ ids.push(id);
747
+ await RequestLogModel.getPuri("w").table("request_logs").where("id", id).update({
748
+ ...costs,
749
+ model_name: "anthropic/claude-sonnet-4-6",
750
+ token_name: "anthropic/test",
751
+ input_tokens: 1_000,
752
+ });
753
+ }
754
+
755
+ const params = { sonamuFilter: { project_name: projectName }, num: 0, page: 1 };
756
+ const { rows } = await RequestLogModel.findMany("C", params);
757
+ expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining(ids.slice(0, 2)));
758
+ expect(rows).toHaveLength(2);
759
+ // Confirmed: .005 + .010; legacy driver repriced to .003, plus .020 image.
760
+ await expect(RequestLogModel.totalCost(params)).resolves.toBeCloseTo(0.038, 10);
761
+ } finally {
762
+ await RequestLogModel.del(ids);
763
+ }
764
+ });
765
+
731
766
  it("does not expire a run while another connection holds its follow-up lock", async () => {
732
767
  const wdb = RequestLogModel.getPuri("w");
733
768
  const client = new Client(wdb.knex.client.config.connection as ClientConfig);
@@ -5,6 +5,7 @@ import {
5
5
  BadRequestException,
6
6
  BaseModelClass,
7
7
  type ListResult,
8
+ normalizeFilterQuery,
8
9
  NotFoundException,
9
10
  Puri,
10
11
  transactional,
@@ -228,6 +229,7 @@ class RequestLogModelClass extends BaseModelClass<
228
229
  id,
229
230
  num: 1,
230
231
  page: 1,
232
+ queryMode: "list",
231
233
  });
232
234
  if (!rows[0]) {
233
235
  throw new NotFoundException(SD("error.entityNotFound")("RequestLog", id));
@@ -244,6 +246,7 @@ class RequestLogModelClass extends BaseModelClass<
244
246
  ...listParams,
245
247
  num: 1,
246
248
  page: 1,
249
+ queryMode: "list",
247
250
  });
248
251
 
249
252
  return rows[0] ?? null;
@@ -304,7 +307,7 @@ class RequestLogModelClass extends BaseModelClass<
304
307
  ...rawParams,
305
308
  } satisfies RequestLogListParams;
306
309
 
307
- const { qb, onSubset: _ } = this.getSubsetQueries(subset);
310
+ const { qb } = this.getSubsetQueries(subset);
308
311
 
309
312
  this.applyListFilters(qb, params);
310
313
 
@@ -320,7 +323,6 @@ class RequestLogModelClass extends BaseModelClass<
320
323
  qb,
321
324
  params,
322
325
  enhancers,
323
- debug: false,
324
326
  });
325
327
  }
326
328
 
@@ -808,10 +810,8 @@ class RequestLogModelClass extends BaseModelClass<
808
810
  /**
809
811
  * 화면 표시용 총 비용. 목록과 같은 필터를 적용한다.
810
812
  *
811
- * 전체를 조건 없이 SUM 한 뒤, `cost_source` 가 없는 legacy row 의 차액만 보정한다.
812
- * 조건 없는 SUM 이어야 `(project_name, cost_usd)` 커버링 인덱스가 Index Only Scan 을
813
- * 탄다 — `cost_source IS NOT NULL` 을 걸면 그 컬럼이 인덱스에 없어 heap 을 다시 봐야
814
- * 하므로 플래너가 인덱스를 버리고 seq scan 으로 돌아간다(실측 230ms → 814ms).
813
+ * driver와 image 비용을 함께 SUM한 뒤, legacy driver 비용의 차액만 보정한다.
814
+ * 이미지 비용은 저장값을 한 번만 포함하고 가격표 재계산 대상에서 제외한다.
815
815
  *
816
816
  * legacy 보정을 남겨둔 이유: 백필로 기존 legacy 는 0 이 되었지만, 외부 로거가
817
817
  * `AppendStepInput`/`FinishRunInput` 으로 costSource 없이 넣으면 다시 생길 수 있다.
@@ -830,18 +830,25 @@ class RequestLogModelClass extends BaseModelClass<
830
830
 
831
831
  const { qb: totalQb } = this.getSubsetQueries("C");
832
832
  this.applyListFilters(totalQb, params);
833
+ const sonamuFilter = params.sonamuFilter
834
+ ? normalizeFilterQuery(params.sonamuFilter)
835
+ : undefined;
836
+ this.applySonamuFilters(totalQb, sonamuFilter);
833
837
  // subset qb 는 컬럼 목록이 이미 SELECT 에 박혀 있어 집계를 얹으면 GROUP BY 에러가 난다.
834
838
  // executeCountQuery 와 같은 방식으로 select 를 비우고 집계만 남긴다.
835
839
  const stored = await totalQb
836
840
  .clear("select")
837
841
  // 음수 저장값(과거 버그)은 0 으로 눌러서 더한다 — JS 쪽 Math.max 와 같은 규칙.
838
842
  .select({
839
- total: Puri.rawNumber("COALESCE(SUM(GREATEST(request_logs.cost_usd, 0)), 0)"),
843
+ total: Puri.rawNumber(
844
+ "COALESCE(SUM(GREATEST(request_logs.cost_usd, 0) + COALESCE(request_logs.image_cost_usd, 0)), 0)",
845
+ ),
840
846
  })
841
847
  .first();
842
848
 
843
849
  const { qb: legacyQb } = this.getSubsetQueries("C");
844
850
  this.applyListFilters(legacyQb, params);
851
+ this.applySonamuFilters(legacyQb, sonamuFilter);
845
852
  const legacyRows = await legacyQb
846
853
  .whereRaw("request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL")
847
854
  .clear("select")
@@ -41,6 +41,7 @@ class RequestLogStepModelClass extends BaseModelClass<
41
41
  id,
42
42
  num: 1,
43
43
  page: 1,
44
+ queryMode: "list",
44
45
  });
45
46
  if (!rows[0]) {
46
47
  throw new NotFoundException(SD("error.entityNotFound")("RequestLogStep", id));
@@ -57,6 +58,7 @@ class RequestLogStepModelClass extends BaseModelClass<
57
58
  ...listParams,
58
59
  num: 1,
59
60
  page: 1,
61
+ queryMode: "list",
60
62
  });
61
63
 
62
64
  return rows[0] ?? null;
@@ -77,7 +79,7 @@ class RequestLogStepModelClass extends BaseModelClass<
77
79
  } satisfies RequestLogStepListParams;
78
80
 
79
81
  // build queries
80
- const { qb, onSubset: _ } = this.getSubsetQueries(subset);
82
+ const { qb } = this.getSubsetQueries(subset);
81
83
 
82
84
  if (params.id) {
83
85
  qb.whereIn("request_log_steps.id", asArray(params.id));
@@ -91,8 +93,6 @@ class RequestLogStepModelClass extends BaseModelClass<
91
93
  if (params.search && params.keyword && params.keyword.length > 0) {
92
94
  if (params.search === "id") {
93
95
  qb.where("request_log_steps.id", Number(params.keyword));
94
- // } else if (params.search === "field") {
95
- // qb.where("request_log_steps.field", "like", `%${params.keyword}%`);
96
96
  } else {
97
97
  throw new BadRequestException(SD("error.unknownSearchField")(params.search));
98
98
  }
@@ -109,22 +109,10 @@ class RequestLogStepModelClass extends BaseModelClass<
109
109
  }
110
110
  }
111
111
 
112
- const enhancers = this.createEnhancers({
113
- A: (row) => ({
114
- ...row,
115
- // 서브셋별로 virtual 필드 계산로직 추가
116
- }),
117
- T: (row) => ({
118
- ...row,
119
- }),
120
- });
121
-
122
112
  return this.executeSubsetQuery({
123
113
  subset,
124
114
  qb,
125
115
  params,
126
- enhancers,
127
- debug: false,
128
116
  });
129
117
  }
130
118
 
@@ -10,6 +10,7 @@ describe("validateSettingValue", () => {
10
10
  // 저장을 막지 않으면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다.
11
11
  expect(validateSettingValue(intDef, "24")).toMatchObject({ ok: false });
12
12
  expect(validateSettingValue(intDef, "-1")).toMatchObject({ ok: false });
13
+ expect(validateSettingValue(intDef, "0")).toMatchObject({ ok: true, value: "0" });
13
14
  expect(validateSettingValue(intDef, "23")).toMatchObject({ ok: true, value: "23" });
14
15
  });
15
16
 
@@ -48,19 +49,6 @@ describe("preset 검증", () => {
48
49
  });
49
50
  });
50
51
 
51
- describe("조용 시간 설정", () => {
52
- // fallback("20"/"8")은 quiet-hours.ts 의 DEFAULT_QUIET_* 와 같아야 하지만 테스트로 묶지
53
- // 않는다 — 두 모듈을 한 파일에서 import 하면 setting.store → token.model 이 딸려와,
54
- // token.model 을 mock 하는 테스트가 같은 워커에 배치될 때 실제 모듈을 먼저 로드해 버린다.
55
- it("0..23 을 벗어나면 거부한다", () => {
56
- const fromDef = findSettingDef("slack.quietFromHour")!;
57
- expect(validateSettingValue(fromDef, "24")).toMatchObject({ ok: false });
58
- expect(validateSettingValue(fromDef, "-1")).toMatchObject({ ok: false });
59
- expect(validateSettingValue(fromDef, "0")).toMatchObject({ ok: true });
60
- expect(validateSettingValue(fromDef, "23")).toMatchObject({ ok: true });
61
- });
62
- });
63
-
64
52
  describe("maskSecret", () => {
65
53
  it("긴 값은 앞뒤만 남긴다", () => {
66
54
  expect(maskSecret("xoxb-1234567890-abcdefghij")).toBe("xoxb-123...ghij");
@@ -90,6 +90,7 @@ class SettingModelClass extends BaseModelClass<
90
90
  id,
91
91
  num: 1,
92
92
  page: 1,
93
+ queryMode: "list",
93
94
  });
94
95
  if (!rows[0]) {
95
96
  throw new NotFoundException(SD("error.entityNotFound")("Setting", id));
@@ -106,6 +107,7 @@ class SettingModelClass extends BaseModelClass<
106
107
  ...listParams,
107
108
  num: 1,
108
109
  page: 1,
110
+ queryMode: "list",
109
111
  });
110
112
 
111
113
  return rows[0] ?? null;
@@ -126,7 +128,7 @@ class SettingModelClass extends BaseModelClass<
126
128
  } satisfies SettingListParams;
127
129
 
128
130
  // build queries
129
- const { qb, onSubset: _ } = this.getSubsetQueries(subset);
131
+ const { qb } = this.getSubsetQueries(subset);
130
132
 
131
133
  // id
132
134
  if (params.id) {
@@ -157,19 +159,10 @@ class SettingModelClass extends BaseModelClass<
157
159
  }
158
160
  }
159
161
 
160
- const enhancers = this.createEnhancers({
161
- A: (row) => ({
162
- ...row,
163
- // 서브셋별로 virtual 필드 계산로직 추가
164
- }),
165
- });
166
-
167
162
  return this.executeSubsetQuery({
168
163
  subset,
169
164
  qb,
170
165
  params,
171
- enhancers,
172
- debug: false,
173
166
  });
174
167
  }
175
168
 
@@ -202,30 +195,21 @@ class SettingModelClass extends BaseModelClass<
202
195
 
203
196
  /** 저장된 설정 전체를 key→value 로. 부팅 시 한 번 읽어 메모리에 올린다. */
204
197
  async findAllAsMap(): Promise<Map<string, string>> {
205
- const { rows } = await this.findMany("A", { num: 0, page: 1 });
198
+ const { rows } = await this.findMany("A", { num: 0, page: 1, queryMode: "list" });
206
199
  return new Map(rows.map((r) => [r.key, r.value]));
207
200
  }
208
201
 
209
- /**
210
- * key 로 upsert 한다. `save` 는 id 기준이라 key 중복을 걸러내지 못해, 기존 행을 먼저 찾아
211
- * id 를 실어 보낸다.
212
- */
202
+ /** 최초 동시 저장도 key unique constraint로 원자적으로 처리한다. */
213
203
  async setByKey(key: string, value: string): Promise<void> {
214
- const existing = await this.findOne("A", { key, num: 1, page: 1 });
215
- await this.save([
216
- {
217
- ...(existing ? { id: existing.id } : {}),
218
- key,
219
- value,
220
- updated_at: new Date(),
221
- },
222
- ]);
204
+ await this.getPuri("w")
205
+ .table("settings")
206
+ .insert({ key, value, updated_at: new Date() })
207
+ .onConflict("key", { update: ["value", "updated_at"] });
223
208
  }
224
209
 
225
210
  /** 저장값을 지워 env 기본값으로 되돌린다. */
226
211
  async clearByKey(key: string): Promise<void> {
227
- const existing = await this.findOne("A", { key, num: 1, page: 1 });
228
- if (existing) await this.del([existing.id]);
212
+ await this.getPuri("w").table("settings").where("key", key).delete();
229
213
  }
230
214
 
231
215
  /**
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { Sonamu } from "sonamu";
4
+ import { beforeAll, expect, it } from "vitest";
5
+
6
+ import { SettingModel } from "./setting.model";
7
+
8
+ beforeAll(async () => {
9
+ await Sonamu.initForTesting();
10
+ });
11
+
12
+ it("atomically saves concurrent first writes to the same setting key", async () => {
13
+ const key = `test.concurrent.${randomUUID()}`;
14
+ try {
15
+ await Promise.all([SettingModel.setByKey(key, "first"), SettingModel.setByKey(key, "second")]);
16
+ const { rows } = await SettingModel.findMany("A", { key, num: 0, page: 1 });
17
+ expect(rows).toHaveLength(1);
18
+ expect(["first", "second"]).toContain(rows[0]!.value);
19
+ await SettingModel.setByKey(key, "final");
20
+ await expect(SettingModel.findOne("A", { key })).resolves.toMatchObject({
21
+ id: rows[0]!.id,
22
+ value: "final",
23
+ });
24
+ } finally {
25
+ await SettingModel.clearByKey(key);
26
+ }
27
+ });
@@ -115,32 +115,99 @@ describe("TokenModel.updateFields", () => {
115
115
  });
116
116
 
117
117
  describe("TokenModel.replaceByAccount", () => {
118
- afterEach(() => {
119
- vi.restoreAllMocks();
118
+ beforeAll(async () => {
119
+ await Sonamu.initForTesting();
120
120
  });
121
121
 
122
- it("re-login으로 row가 교체돼도 기존 keepalive 선택을 보존한다", async () => {
123
- vi.spyOn(TokenModel, "findByAccountIdentifier").mockResolvedValue([
122
+ it("replaces duplicate account rows while preserving routing settings and renewing login state", async () => {
123
+ const accountUuid = randomUUID();
124
+ const credentials = { ...baseToken.credentials, accountUuid };
125
+ const ids = await TokenModel.save([
124
126
  {
125
- id: 1,
126
- created_at: new Date(),
127
- active: true,
128
- reauth_required: false,
129
- ord: 0,
130
- quota_threshold: 80,
131
- weight: 1,
132
- keepalive_enabled: true,
133
127
  ...baseToken,
128
+ name: `replace-first-${accountUuid}`,
129
+ credentials,
130
+ active: false,
131
+ reauth_required: true,
132
+ ord: 9,
133
+ quota_threshold: null,
134
+ weight: 7,
135
+ keepalive_enabled: false,
134
136
  },
137
+ { ...baseToken, name: `replace-second-${accountUuid}`, credentials, keepalive_enabled: true },
135
138
  ]);
136
- vi.spyOn(TokenModel, "del").mockResolvedValue(1);
137
- const save = vi.spyOn(TokenModel, "save").mockResolvedValue([2]);
139
+ const refreshed = { ...credentials, accessToken: "refreshed-access-token" };
140
+ try {
141
+ await TokenModel.replaceByAccount("anthropic", accountUuid, {
142
+ ...baseToken,
143
+ name: `replace-first-${accountUuid}`,
144
+ credentials: refreshed,
145
+ });
146
+ const rows = await TokenModel.findByAccountIdentifier("A", "anthropic", accountUuid);
147
+ expect(rows).toHaveLength(1);
148
+ expect(ids).not.toContain(rows[0]!.id);
149
+ expect(rows[0]).toMatchObject({
150
+ credentials: refreshed,
151
+ active: true,
152
+ reauth_required: false,
153
+ ord: 9,
154
+ quota_threshold: null,
155
+ weight: 7,
156
+ keepalive_enabled: true,
157
+ });
158
+ } finally {
159
+ const rows = await TokenModel.findByAccountIdentifier("A", "anthropic", accountUuid);
160
+ await TokenModel.del(rows.map((row) => row.id));
161
+ }
162
+ });
138
163
 
139
- await TokenModel.replaceByAccount("anthropic", "acc-1", baseToken);
164
+ it("rolls back the deletion when the replacement violates a database constraint", async () => {
165
+ const accountUuid = randomUUID();
166
+ const original = {
167
+ ...baseToken,
168
+ name: `rollback-${accountUuid}`,
169
+ credentials: { ...baseToken.credentials, accountUuid },
170
+ quota_threshold: 42,
171
+ weight: 3,
172
+ ord: 8,
173
+ keepalive_enabled: true,
174
+ };
175
+ const [id] = await TokenModel.save([original]);
176
+ try {
177
+ await expect(
178
+ TokenModel.replaceByAccount("anthropic", accountUuid, {
179
+ ...original,
180
+ provider: "x".repeat(21),
181
+ }),
182
+ ).rejects.toThrow();
183
+ await expect(TokenModel.findById("A", id!)).resolves.toMatchObject(original);
184
+ } finally {
185
+ await TokenModel.del([id!]);
186
+ }
187
+ });
140
188
 
141
- expect(save).toHaveBeenCalledWith([
142
- expect.objectContaining({ keepalive_enabled: true }),
143
- ]);
189
+ it("loads every active or expired token when a pool exceeds 100 accounts", async () => {
190
+ const suffix = randomUUID();
191
+ const ids = await TokenModel.save(
192
+ Array.from({ length: 101 }, (_, index) => ({
193
+ ...baseToken,
194
+ name: `pool-${suffix}-${index}`,
195
+ credentials: { ...baseToken.credentials, accountUuid: suffix },
196
+ reauth_required: true,
197
+ })),
198
+ );
199
+ try {
200
+ for (const rows of [
201
+ await TokenModel.findActive("A"),
202
+ await TokenModel.findActiveByProvider("A", "anthropic"),
203
+ await TokenModel.findReauthRequired("A"),
204
+ await TokenModel.findByAccountIdentifier("A", "anthropic", suffix),
205
+ ]) {
206
+ expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining(ids));
207
+ }
208
+ } finally {
209
+ await TokenModel.del(ids);
210
+ }
144
211
  });
145
212
  });
146
213
 
@@ -176,7 +243,7 @@ describe("TokenModel.findActiveByProviderAndName", () => {
176
243
  ["tokens.name", "anthropic/tok-A"],
177
244
  ]);
178
245
  expect(executeSubsetQuery).toHaveBeenCalledWith(
179
- expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1 } }),
246
+ expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1, queryMode: "list" } }),
180
247
  );
181
248
  });
182
249
  });