90 lines
3.3 KiB
TypeScript
90 lines
3.3 KiB
TypeScript
|
|
import { randomBytes } from 'node:crypto';
|
||
|
|
import postgres from 'postgres';
|
||
|
|
import { allTestMigrations } from '../src/__tests__/all-test-migrations';
|
||
|
|
import { runMigrations } from '../src/shared/db';
|
||
|
|
|
||
|
|
export const DEFAULT_ADMIN_DB_URL =
|
||
|
|
process.env['QUINN_TEST_ADMIN_DB_URL'] ??
|
||
|
|
(process.env['QUINN_TEST_DB_URL'] ?? 'postgresql://quinn:devpassword@black.lan:25435/quinn_test').replace(
|
||
|
|
/\/[^/]+$/,
|
||
|
|
'/postgres',
|
||
|
|
);
|
||
|
|
|
||
|
|
const SWEEP_AGE_SECONDS = 2 * 3600;
|
||
|
|
const SWEEP_MAX_PER_RUN = 50;
|
||
|
|
|
||
|
|
function testDbAgeSeconds(datname: string, nowMs: number): number | null {
|
||
|
|
const match = /^quinn_test_(\d{10})_[0-9a-f]+$/.exec(datname);
|
||
|
|
if (!match) return null;
|
||
|
|
return Math.floor(nowMs / 1000) - Number(match[1]);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function sweepOrphanTestDbs(
|
||
|
|
adminUrl: string = DEFAULT_ADMIN_DB_URL,
|
||
|
|
opts?: { keep?: Set<string> },
|
||
|
|
): Promise<number> {
|
||
|
|
const keep = opts?.keep ?? new Set<string>();
|
||
|
|
const admin = postgres(adminUrl, { max: 1, connect_timeout: 10, onnotice: () => { /* quiet */ } });
|
||
|
|
let dropped = 0;
|
||
|
|
try {
|
||
|
|
const nowMs = Date.now();
|
||
|
|
const rows = await admin<Array<{ datname: string }>>`
|
||
|
|
SELECT datname FROM pg_database WHERE datname LIKE 'quinn_test\\_%'
|
||
|
|
`;
|
||
|
|
for (const { datname } of rows) {
|
||
|
|
if (dropped >= SWEEP_MAX_PER_RUN) break;
|
||
|
|
if (keep.has(datname)) continue;
|
||
|
|
const age = testDbAgeSeconds(datname, nowMs);
|
||
|
|
if (age !== null && age < SWEEP_AGE_SECONDS) continue;
|
||
|
|
try {
|
||
|
|
await admin.unsafe(`DROP DATABASE IF EXISTS "${datname}" WITH (FORCE)`);
|
||
|
|
dropped += 1;
|
||
|
|
} catch { /* held by a connection */ }
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
await admin.end({ timeout: 5 }).catch(() => { /* ignore */ });
|
||
|
|
}
|
||
|
|
return dropped;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function buildTestTemplate(adminUrl: string = DEFAULT_ADMIN_DB_URL): Promise<string> {
|
||
|
|
const name = `quinn_test_template_${Math.floor(Date.now() / 1000)}_${randomBytes(4).toString('hex')}`;
|
||
|
|
const admin = postgres(adminUrl, { max: 1, connect_timeout: 15, onnotice: () => { /* quiet */ } });
|
||
|
|
const dbUrl = adminUrl.replace(/\/[^/]+$/, `/${name}`);
|
||
|
|
try {
|
||
|
|
await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
|
||
|
|
await admin.unsafe(`CREATE DATABASE "${name}"`);
|
||
|
|
} finally {
|
||
|
|
await admin.end({ timeout: 5 }).catch(() => { /* ignore */ });
|
||
|
|
}
|
||
|
|
|
||
|
|
const sql = postgres(dbUrl, { max: 4, connect_timeout: 15, onnotice: () => { /* quiet */ } });
|
||
|
|
try {
|
||
|
|
await sql.unsafe('CREATE SCHEMA IF NOT EXISTS ai');
|
||
|
|
await runMigrations(sql, allTestMigrations);
|
||
|
|
} finally {
|
||
|
|
await sql.end({ timeout: 10 }).catch(() => { /* ignore */ });
|
||
|
|
}
|
||
|
|
return name;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function dropTestDatabase(name: string, adminUrl: string = DEFAULT_ADMIN_DB_URL): Promise<void> {
|
||
|
|
if (!/^quinn_test_[0-9a-z_]+$/.test(name)) {
|
||
|
|
throw new Error(`refusing to drop ${name}`);
|
||
|
|
}
|
||
|
|
const admin = postgres(adminUrl, { max: 1, connect_timeout: 10, onnotice: () => { /* quiet */ } });
|
||
|
|
try {
|
||
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||
|
|
await Bun.sleep(500 * (attempt + 1));
|
||
|
|
try {
|
||
|
|
await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
/* retry */
|
||
|
|
}
|
||
|
|
}
|
||
|
|
await admin.unsafe(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`);
|
||
|
|
} finally {
|
||
|
|
await admin.end({ timeout: 5 }).catch(() => { /* ignore */ });
|
||
|
|
}
|
||
|
|
}
|