@cartanova/qgrid-cli 2.4.2 → 2.4.4

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 (95) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
  2. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  4. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  5. package/bundle/dist/application/sonamu.generated.js +5 -3
  6. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  7. package/bundle/dist/application/token/token.model.js +8 -3
  8. package/bundle/dist/application/token/token.types.js +8 -3
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/index.js +3 -6
  11. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  12. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  13. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  14. package/bundle/dist/server-bootstrap.js +17 -0
  15. package/bundle/dist/sonamu.config.js +2 -10
  16. package/bundle/dist/startup-migrations.js +21 -0
  17. package/bundle/dist/testing/global.js +93 -5
  18. package/bundle/dist/testing/test-database-name.js +22 -0
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  20. package/bundle/dist/utils/providers/common/model-cost.js +22 -1
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  26. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  27. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  28. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  29. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  30. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  31. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  32. package/bundle/src/application/sonamu.generated.http +4 -2
  33. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  34. package/bundle/src/application/sonamu.generated.ts +10 -1
  35. package/bundle/src/application/token/token.entity.json +17 -1
  36. package/bundle/src/application/token/token.model.test.ts +43 -0
  37. package/bundle/src/application/token/token.model.ts +18 -2
  38. package/bundle/src/application/token/token.types.test.ts +11 -0
  39. package/bundle/src/application/token/token.types.ts +3 -0
  40. package/bundle/src/i18n/sd.generated.ts +1 -0
  41. package/bundle/src/index.ts +2 -5
  42. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  43. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  44. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  45. package/bundle/src/server-bootstrap.test.ts +39 -0
  46. package/bundle/src/server-bootstrap.ts +21 -0
  47. package/bundle/src/sonamu.config.ts +3 -12
  48. package/bundle/src/startup-migrations.test.ts +25 -0
  49. package/bundle/src/startup-migrations.ts +20 -0
  50. package/bundle/src/testing/global.test.ts +130 -0
  51. package/bundle/src/testing/global.ts +143 -13
  52. package/bundle/src/testing/test-database-name.test.ts +38 -0
  53. package/bundle/src/testing/test-database-name.ts +21 -0
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  56. package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
  57. package/bundle/src/utils/providers/common/model-cost.ts +31 -0
  58. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  60. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
  62. package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  65. package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
  66. package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
  67. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  68. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  69. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  70. package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
  71. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  72. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  73. package/bundle/web-dist/client/index.html +2 -2
  74. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  75. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  76. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  77. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  78. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  79. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  80. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  81. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  82. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  83. package/package.json +1 -1
  84. package/skills/qgrid/SKILL.md +1 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  86. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  87. package/skills/qgrid/references/decision-rationale.md +23 -0
  88. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  89. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  90. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  91. package/skills/qgrid/references/repo-map.md +2 -0
  92. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  93. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  94. package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
  95. package/bundle/web-dist/client/assets/tokens-XAhJyLqj.js +0 -1
@@ -0,0 +1,38 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("tokens", (table) => {
5
+ table.integer("weight").notNullable().defaultTo(1);
6
+ });
7
+ await knex.raw(`
8
+ CREATE OR REPLACE FUNCTION public.tokens_weight_notify() RETURNS trigger
9
+ LANGUAGE plpgsql
10
+ SET search_path = pg_catalog, public
11
+ AS $func$
12
+ BEGIN
13
+ PERFORM pg_notify('tokens_changed', json_build_object(
14
+ 'op', TG_OP,
15
+ 'id', NEW.id
16
+ )::text);
17
+ RETURN NEW;
18
+ END;
19
+ $func$;
20
+
21
+ DROP TRIGGER IF EXISTS tokens_weight_changed_upd ON public.tokens;
22
+ CREATE TRIGGER tokens_weight_changed_upd
23
+ AFTER UPDATE OF weight ON public.tokens
24
+ FOR EACH ROW
25
+ WHEN (OLD.weight IS DISTINCT FROM NEW.weight)
26
+ EXECUTE FUNCTION public.tokens_weight_notify();
27
+ `);
28
+ }
29
+
30
+ export async function down(knex: Knex): Promise<void> {
31
+ await knex.raw(`
32
+ DROP TRIGGER IF EXISTS tokens_weight_changed_upd ON public.tokens;
33
+ DROP FUNCTION IF EXISTS public.tokens_weight_notify();
34
+ `);
35
+ await knex.schema.alterTable("tokens", (table) => {
36
+ table.dropColumns("weight");
37
+ });
38
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { bootstrapServer } from "./server-bootstrap";
4
+
5
+ describe("server bootstrap", () => {
6
+ it("finishes required migrations before binding the server socket", async () => {
7
+ const calls: string[] = [];
8
+ const init = vi.fn(async () => {
9
+ calls.push("init");
10
+ });
11
+ const migrate = vi.fn(async () => {
12
+ calls.push("migrate");
13
+ });
14
+ const listen = vi.fn(async () => {
15
+ calls.push("listen");
16
+ });
17
+
18
+ await bootstrapServer({ init, migrate, listen });
19
+
20
+ expect(calls).toEqual(["init", "migrate", "listen"]);
21
+ });
22
+
23
+ it("does not bind the socket when a required migration fails", async () => {
24
+ const failure = new Error("migration failed");
25
+ const listen = vi.fn();
26
+
27
+ await expect(
28
+ bootstrapServer({
29
+ init: vi.fn(async () => {}),
30
+ migrate: vi.fn(async () => {
31
+ throw failure;
32
+ }),
33
+ listen,
34
+ }),
35
+ ).rejects.toBe(failure);
36
+
37
+ expect(listen).not.toHaveBeenCalled();
38
+ });
39
+ });
@@ -0,0 +1,21 @@
1
+ import { Sonamu } from "sonamu";
2
+
3
+ import { runRequiredMigrations } from "./startup-migrations";
4
+
5
+ type BootstrapSteps = {
6
+ init: () => Promise<void>;
7
+ migrate: () => Promise<void>;
8
+ listen: () => Promise<unknown>;
9
+ };
10
+
11
+ export async function bootstrapServer(
12
+ steps: BootstrapSteps = {
13
+ init: () => Sonamu.init(),
14
+ migrate: runRequiredMigrations,
15
+ listen: () => Sonamu.createServer(),
16
+ },
17
+ ): Promise<void> {
18
+ await steps.init();
19
+ await steps.migrate();
20
+ await steps.listen();
21
+ }
@@ -11,7 +11,6 @@ import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
11
11
  import { QgridFrame } from "./application/qgrid/qgrid.frame";
12
12
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
13
13
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
14
- import { TokenModel } from "./application/token/token.model";
15
14
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
16
15
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
17
16
 
@@ -94,6 +93,9 @@ export default defineConfig({
94
93
  },
95
94
  server: {
96
95
  listen: { port, host },
96
+ fastify: {
97
+ bodyLimit: 10 * 1024 * 1024,
98
+ },
97
99
  plugins: {
98
100
  formbody: true,
99
101
  qs: true,
@@ -178,17 +180,6 @@ export default defineConfig({
178
180
  onStart: async () => {
179
181
  const log = getLogger(["qgrid", "startup"]);
180
182
 
181
- try {
182
- const knex = TokenModel.getDB("w");
183
- const migrationsDir = path.join(import.meta.dirname, "../src/migrations");
184
- const [batch, migrations] = await knex.migrate.latest({ directory: migrationsDir });
185
- if (migrations.length > 0) {
186
- log.info(`migration: ${migrations.length} applied (batch ${batch})`);
187
- }
188
- } catch (e) {
189
- log.warn(`migration skipped: ${(e as Error).message}`);
190
- }
191
-
192
183
  let triggerReady = true;
193
184
  try {
194
185
  await ensureTokensTrigger(connConfig);
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const { latest } = vi.hoisted(() => ({ latest: vi.fn() }));
4
+
5
+ vi.mock("./application/token/token.model", () => ({
6
+ TokenModel: {
7
+ getDB: () => ({ migrate: { latest } }),
8
+ },
9
+ }));
10
+
11
+ import { runRequiredMigrations } from "./startup-migrations";
12
+
13
+ describe("required startup migrations", () => {
14
+ it("terminates with a failure status when migration fails", async () => {
15
+ const failure = new Error("tokens.weight migration failed");
16
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
17
+ throw failure;
18
+ });
19
+ latest.mockRejectedValueOnce(failure);
20
+
21
+ await expect(runRequiredMigrations()).rejects.toBe(failure);
22
+
23
+ expect(exit).toHaveBeenCalledWith(1);
24
+ });
25
+ });
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+
3
+ import { getLogger } from "@logtape/logtape";
4
+
5
+ import { TokenModel } from "./application/token/token.model";
6
+
7
+ export async function runRequiredMigrations(): Promise<void> {
8
+ const log = getLogger(["qgrid", "startup"]);
9
+ try {
10
+ 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 });
13
+ if (migrations.length > 0) {
14
+ log.info(`migration: ${migrations.length} applied (batch ${batch})`);
15
+ }
16
+ } catch (error) {
17
+ log.error(`migration failed: ${(error as Error).message}`);
18
+ process.exit(1);
19
+ }
20
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ dropWorkerDatabases,
5
+ provisionWorkerDatabases,
6
+ recreateWorkerDatabases,
7
+ resolveMaxWorkers,
8
+ } from "./global";
9
+
10
+ describe("parallel test database setup", () => {
11
+ it("recreates worker databases from the template through an admin connection", async () => {
12
+ const query = vi.fn().mockResolvedValue({});
13
+
14
+ await recreateWorkerDatabases(
15
+ { query } as never,
16
+ "qgrid_test",
17
+ "qgrid_test_run_a",
18
+ 2,
19
+ );
20
+
21
+ const sql = query.mock.calls.map(([text]) => String(text).replaceAll(/\s+/g, " ").trim());
22
+ expect(sql).toContain('DROP DATABASE IF EXISTS "qgrid_test_run_a_1"');
23
+ expect(sql).toContain(
24
+ 'CREATE DATABASE "qgrid_test_run_a_1" TEMPLATE "qgrid_test" STRATEGY FILE_COPY',
25
+ );
26
+ expect(sql).toContain('DROP DATABASE IF EXISTS "qgrid_test_run_a_2"');
27
+ expect(query).toHaveBeenCalledWith(expect.stringContaining("pg_terminate_backend"), [
28
+ "qgrid_test",
29
+ ]);
30
+ });
31
+
32
+ it("terminates worker sessions before dropping worker databases", async () => {
33
+ const query = vi.fn().mockResolvedValue({});
34
+
35
+ await dropWorkerDatabases({ query } as never, "qgrid_test_run_a", 1);
36
+
37
+ expect(query.mock.calls[0]?.[1]).toEqual(["qgrid_test_run_a_1"]);
38
+ expect(query.mock.calls[1]?.[0]).toBe('DROP DATABASE IF EXISTS "qgrid_test_run_a_1"');
39
+ });
40
+
41
+ it("attempts cleanup for every planned database after individual failures", async () => {
42
+ const query = vi.fn(async (text: string, values?: string[]) => {
43
+ if (values?.[0] === "qgrid_test_run_a_1" || text.includes('"qgrid_test_run_a_1"')) {
44
+ throw new Error("db1 cleanup failed");
45
+ }
46
+ return {};
47
+ });
48
+
49
+ await expect(
50
+ dropWorkerDatabases({ query } as never, "qgrid_test_run_a", 2),
51
+ ).rejects.toBeInstanceOf(AggregateError);
52
+
53
+ expect(query).toHaveBeenCalledWith(expect.stringContaining("pg_terminate_backend"), [
54
+ "qgrid_test_run_a_2",
55
+ ]);
56
+ expect(query).toHaveBeenCalledWith('DROP DATABASE IF EXISTS "qgrid_test_run_a_2"');
57
+ });
58
+
59
+ it("uses the worker count resolved by Vitest", () => {
60
+ expect(resolveMaxWorkers({ config: { maxWorkers: 5 } })).toBe(5);
61
+ });
62
+
63
+ it("uses a fresh admin connection to clean every planned DB after setup failure", async () => {
64
+ const setupQuery = vi.fn(async (text: string) => {
65
+ if (text.includes('CREATE DATABASE "qgrid_test_run_a_2"')) {
66
+ throw new Error("clone failed");
67
+ }
68
+ return {};
69
+ });
70
+ const cleanupQuery = vi.fn().mockResolvedValue({});
71
+ const createClient = vi
72
+ .fn()
73
+ .mockReturnValueOnce({
74
+ connect: vi.fn().mockResolvedValue(undefined),
75
+ end: vi.fn().mockResolvedValue(undefined),
76
+ query: setupQuery,
77
+ })
78
+ .mockReturnValueOnce({
79
+ connect: vi.fn().mockResolvedValue(undefined),
80
+ end: vi.fn().mockResolvedValue(undefined),
81
+ query: cleanupQuery,
82
+ });
83
+
84
+ await expect(
85
+ provisionWorkerDatabases(
86
+ createClient,
87
+ "qgrid_test",
88
+ "qgrid_test_run_a",
89
+ 2,
90
+ ),
91
+ ).rejects.toThrow("clone failed");
92
+
93
+ const drops = cleanupQuery.mock.calls
94
+ .map(([text]) => String(text).replaceAll(/\s+/g, " ").trim())
95
+ .filter((text) => text.startsWith("DROP DATABASE"));
96
+ expect(createClient).toHaveBeenCalledTimes(2);
97
+ expect(drops).toContain('DROP DATABASE IF EXISTS "qgrid_test_run_a_1"');
98
+ expect(drops).toContain('DROP DATABASE IF EXISTS "qgrid_test_run_a_2"');
99
+ });
100
+
101
+ it("preserves both setup and cleanup failures", async () => {
102
+ const setupError = new Error("clone failed");
103
+ const cleanupError = new Error("cleanup failed");
104
+ const createClient = vi
105
+ .fn()
106
+ .mockReturnValueOnce({
107
+ connect: vi.fn().mockResolvedValue(undefined),
108
+ end: vi.fn().mockResolvedValue(undefined),
109
+ query: vi.fn().mockRejectedValue(setupError),
110
+ })
111
+ .mockReturnValueOnce({
112
+ connect: vi.fn().mockResolvedValue(undefined),
113
+ end: vi.fn().mockResolvedValue(undefined),
114
+ query: vi.fn().mockRejectedValue(cleanupError),
115
+ });
116
+
117
+ const rejected = provisionWorkerDatabases(
118
+ createClient,
119
+ "qgrid_test",
120
+ "qgrid_test_run_a",
121
+ 2,
122
+ ).catch((error: unknown) => error);
123
+
124
+ const combinedError = (await rejected) as AggregateError;
125
+ expect(combinedError).toBeInstanceOf(AggregateError);
126
+ expect(combinedError.errors[0]).toBe(setupError);
127
+ expect(combinedError.errors[1]).toBeInstanceOf(AggregateError);
128
+ expect((combinedError.errors[1] as AggregateError).errors).toContain(cleanupError);
129
+ });
130
+ });
@@ -1,20 +1,150 @@
1
1
  import dotenv from "dotenv";
2
+ import { Client } from "pg";
2
3
 
3
4
  dotenv.config();
4
5
 
5
- type SonamuGlobalSetupModule = {
6
- setup: () => Promise<(() => Promise<void>) | void>;
7
- };
8
-
9
- // sonamu/test 의 barrel export 는 bootstrap.js 를 함께 로드하고, bootstrap.js 는 top-level 에서
10
- // vitest 를 import 한다. Vitest 4 globalSetup 컨텍스트에서는 이 import 가 internal state 에러를
11
- // 만들기 때문에 global-setup.js 만 직접 로드한다.
12
- export async function setup(): Promise<(() => Promise<void>) | void> {
13
- const globalSetupUrl = new URL(
14
- "../../node_modules/sonamu/dist/testing/global-setup.js",
15
- import.meta.url,
6
+ const MAX_WORKERS = 4;
7
+ const TEMPLATE_DB = process.env.QGRID_TEST_DB_NAME ?? "qgrid_test";
8
+
9
+ type QueryClient = Pick<Client, "query">;
10
+ type AdminClient = Pick<Client, "connect" | "end" | "query">;
11
+ type AdminClientFactory = () => AdminClient;
12
+ type VitestProject = { config?: { maxWorkers?: number } };
13
+
14
+ function quoteIdentifier(value: string): string {
15
+ if (!/^[A-Za-z0-9_]+$/.test(value)) {
16
+ throw new Error(`invalid database identifier: ${value}`);
17
+ }
18
+ return `"${value}"`;
19
+ }
20
+
21
+ function workerDatabaseNames(templateDb: string, maxWorkers: number): string[] {
22
+ return Array.from({ length: maxWorkers }, (_, index) => `${templateDb}_${index + 1}`);
23
+ }
24
+
25
+ async function terminateConnections(client: QueryClient, database: string): Promise<void> {
26
+ await client.query(
27
+ `
28
+ SELECT pg_terminate_backend(pid)
29
+ FROM pg_stat_activity
30
+ WHERE datname = $1
31
+ AND pid <> pg_backend_pid()
32
+ `,
33
+ [database],
16
34
  );
17
- const { setup: sonamuSetup } = (await import(globalSetupUrl.href)) as SonamuGlobalSetupModule;
35
+ }
36
+
37
+ export async function recreateWorkerDatabases(
38
+ client: QueryClient,
39
+ templateDb: string,
40
+ runDatabase: string,
41
+ maxWorkers: number,
42
+ ): Promise<string[]> {
43
+ const workers = workerDatabaseNames(runDatabase, maxWorkers);
44
+ for (const database of [...workers, templateDb]) {
45
+ await terminateConnections(client, database);
46
+ }
47
+ for (const database of workers) {
48
+ await client.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`);
49
+ await client.query(
50
+ `CREATE DATABASE ${quoteIdentifier(database)} TEMPLATE ${quoteIdentifier(templateDb)} STRATEGY FILE_COPY`,
51
+ );
52
+ }
53
+ return workers;
54
+ }
55
+
56
+ export async function dropWorkerDatabases(
57
+ client: QueryClient,
58
+ templateDb: string,
59
+ maxWorkers: number,
60
+ ): Promise<void> {
61
+ const errors: unknown[] = [];
62
+ for (const database of workerDatabaseNames(templateDb, maxWorkers)) {
63
+ await terminateConnections(client, database).catch((error) => {
64
+ errors.push(error);
65
+ });
66
+ await client.query(`DROP DATABASE IF EXISTS ${quoteIdentifier(database)}`).catch((error) => {
67
+ errors.push(error);
68
+ });
69
+ }
70
+ if (errors.length > 0) {
71
+ const cleanupError = new AggregateError(errors, "failed to clean one or more test databases", {
72
+ cause: errors[0],
73
+ });
74
+ throw cleanupError;
75
+ }
76
+ }
77
+
78
+ function createAdminClient(): Client {
79
+ return new Client({
80
+ host: process.env.QGRID_DB_HOST ?? "localhost",
81
+ port: Number(process.env.QGRID_DB_PORT ?? 5432),
82
+ user: process.env.QGRID_DB_USER ?? "postgres",
83
+ password: process.env.QGRID_DB_PASSWORD ?? "postgres",
84
+ database: "postgres",
85
+ application_name: "qgrid-vitest-global-setup",
86
+ });
87
+ }
88
+
89
+ export function resolveMaxWorkers(project?: VitestProject): number {
90
+ const maxWorkers = project?.config?.maxWorkers;
91
+ return Number.isInteger(maxWorkers) && maxWorkers !== undefined && maxWorkers > 0
92
+ ? maxWorkers
93
+ : MAX_WORKERS;
94
+ }
95
+
96
+ export async function provisionWorkerDatabases(
97
+ createClient: AdminClientFactory,
98
+ templateDb: string,
99
+ runDatabase: string,
100
+ maxWorkers: number,
101
+ ): Promise<void> {
102
+ const client = createClient();
103
+ await client.connect();
104
+ try {
105
+ await recreateWorkerDatabases(client, templateDb, runDatabase, maxWorkers);
106
+ } catch (setupError) {
107
+ await client.end().catch(() => {});
108
+ const cleanupClient = createClient();
109
+ let cleanupError: unknown;
110
+ try {
111
+ await cleanupClient.connect();
112
+ await dropWorkerDatabases(cleanupClient, runDatabase, maxWorkers);
113
+ } catch (error) {
114
+ cleanupError = error;
115
+ } finally {
116
+ await cleanupClient.end().catch((error) => {
117
+ cleanupError ??= error;
118
+ });
119
+ }
120
+ if (cleanupError) {
121
+ const combinedError = new AggregateError(
122
+ [setupError, cleanupError],
123
+ "test database setup and cleanup both failed",
124
+ { cause: setupError },
125
+ );
126
+ throw combinedError;
127
+ }
128
+ throw setupError;
129
+ }
130
+ await client.end();
131
+ }
132
+
133
+ export async function setup(project?: VitestProject): Promise<() => Promise<void>> {
134
+ const runDatabase = process.env.QGRID_TEST_RUN_DB;
135
+ if (!runDatabase || process.env.QGRID_DB_NAME !== runDatabase) {
136
+ throw new Error("Vitest test database name was not configured consistently");
137
+ }
138
+ const maxWorkers = resolveMaxWorkers(project);
139
+ await provisionWorkerDatabases(createAdminClient, TEMPLATE_DB, runDatabase, maxWorkers);
18
140
 
19
- return sonamuSetup();
141
+ return async () => {
142
+ const teardownClient = createAdminClient();
143
+ await teardownClient.connect();
144
+ try {
145
+ await dropWorkerDatabases(teardownClient, runDatabase, maxWorkers);
146
+ } finally {
147
+ await teardownClient.end();
148
+ }
149
+ };
20
150
  }
@@ -0,0 +1,38 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ configureTestRunDatabaseName,
5
+ createTestRunDatabaseName,
6
+ } from "./test-database-name";
7
+
8
+ const originalRunDatabase = process.env.QGRID_TEST_RUN_DB;
9
+ const originalDatabase = process.env.QGRID_DB_NAME;
10
+
11
+ describe("test run database name", () => {
12
+ afterEach(() => {
13
+ if (originalRunDatabase === undefined) delete process.env.QGRID_TEST_RUN_DB;
14
+ else process.env.QGRID_TEST_RUN_DB = originalRunDatabase;
15
+ if (originalDatabase === undefined) delete process.env.QGRID_DB_NAME;
16
+ else process.env.QGRID_DB_NAME = originalDatabase;
17
+ });
18
+
19
+ it("uses one run-unique basename for setup and Sonamu workers", () => {
20
+ delete process.env.QGRID_TEST_RUN_DB;
21
+ process.env.QGRID_DB_NAME = "qgrid";
22
+
23
+ const runDatabase = configureTestRunDatabaseName();
24
+
25
+ expect(runDatabase).toMatch(/^qgrid_test_[A-Za-z0-9_]+$/);
26
+ expect(process.env.QGRID_TEST_RUN_DB).toBe(runDatabase);
27
+ expect(process.env.QGRID_DB_NAME).toBe(runDatabase);
28
+ });
29
+
30
+ it("adds controller-specific entropy when processes share a prefix", () => {
31
+ expect(createTestRunDatabaseName("shared_ci", "controller_1")).toBe(
32
+ "shared_ci_controller_1",
33
+ );
34
+ expect(createTestRunDatabaseName("shared_ci", "controller_2")).toBe(
35
+ "shared_ci_controller_2",
36
+ );
37
+ });
38
+ });
@@ -0,0 +1,21 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ let configuredRunDatabase: string | undefined;
4
+
5
+ export function createTestRunDatabaseName(prefix: string, entropy: string): string {
6
+ const safePrefix = prefix.replaceAll(/[^A-Za-z0-9_]/g, "_");
7
+ const safeEntropy = entropy.replaceAll(/[^A-Za-z0-9_]/g, "_");
8
+ const maxPrefixLength = 63 - safeEntropy.length - 1;
9
+ return `${safePrefix.slice(0, maxPrefixLength)}_${safeEntropy}`;
10
+ }
11
+
12
+ export function configureTestRunDatabaseName(): string {
13
+ if (configuredRunDatabase) return configuredRunDatabase;
14
+ const prefix = process.env.QGRID_TEST_RUN_DB ?? "qgrid_test";
15
+ const entropy = `${process.pid}_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
16
+ const runDatabase = createTestRunDatabaseName(prefix, entropy);
17
+ configuredRunDatabase = runDatabase;
18
+ process.env.QGRID_TEST_RUN_DB = runDatabase;
19
+ process.env.QGRID_DB_NAME = runDatabase;
20
+ return runDatabase;
21
+ }