@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.
- package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
- package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
- package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +8 -3
- package/bundle/dist/application/token/token.types.js +8 -3
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/index.js +3 -6
- package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
- package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
- package/bundle/dist/server-bootstrap.js +17 -0
- package/bundle/dist/sonamu.config.js +2 -10
- package/bundle/dist/startup-migrations.js +21 -0
- package/bundle/dist/testing/global.js +93 -5
- package/bundle/dist/testing/test-database-name.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
- package/bundle/dist/utils/providers/common/model-cost.js +22 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
- package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
- package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +10 -1
- package/bundle/src/application/token/token.entity.json +17 -1
- package/bundle/src/application/token/token.model.test.ts +43 -0
- package/bundle/src/application/token/token.model.ts +18 -2
- package/bundle/src/application/token/token.types.test.ts +11 -0
- package/bundle/src/application/token/token.types.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/index.ts +2 -5
- package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
- package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
- package/bundle/src/server-bootstrap.test.ts +39 -0
- package/bundle/src/server-bootstrap.ts +21 -0
- package/bundle/src/sonamu.config.ts +3 -12
- package/bundle/src/startup-migrations.test.ts +25 -0
- package/bundle/src/startup-migrations.ts +20 -0
- package/bundle/src/testing/global.test.ts +130 -0
- package/bundle/src/testing/global.ts +143 -13
- package/bundle/src/testing/test-database-name.test.ts +38 -0
- package/bundle/src/testing/test-database-name.ts +21 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
- package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
- package/bundle/src/utils/providers/common/model-cost.ts +31 -0
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
- package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
- package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
- package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
- package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
- package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
- package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
- package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/openai-codex-runtime.md +4 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
- package/skills/qgrid/references/provider-runtime-differences.md +2 -1
- package/skills/qgrid/references/repo-map.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
- package/skills/qgrid/references/verification-and-debugging.md +3 -0
- package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|