184 lines
4.9 KiB
TypeScript
184 lines
4.9 KiB
TypeScript
import Database from "bun:sqlite";
|
||
import { randomUUID } from "node:crypto";
|
||
import { mkdirSync, rmSync } from "node:fs";
|
||
import { rm } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
|
||
import type { MigrationRecord } from "../src/server/db/load-migrations";
|
||
|
||
import { createDatabase } from "../src/server/db/connection";
|
||
import { loadMigrationsFromDir } from "../src/server/db/load-migrations";
|
||
import { runMigrations } from "../src/server/db/migrate";
|
||
import { createMemoryLogger } from "../src/server/logger";
|
||
|
||
const RETRYABLE_RM_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]);
|
||
|
||
export interface TestDatabaseHandle {
|
||
cleanup: () => void;
|
||
close: () => void;
|
||
db: Database;
|
||
dir: string;
|
||
logger: ReturnType<typeof createMemoryLogger>;
|
||
}
|
||
|
||
export function closeSqliteForTest(db: Database): void {
|
||
try {
|
||
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||
} catch {
|
||
// 关闭前的 checkpoint 是尽力操作;失败时仍继续 close,避免掩盖原测试断言。
|
||
}
|
||
|
||
try {
|
||
db.exec("PRAGMA journal_mode = DELETE");
|
||
} catch {
|
||
// Windows 上 WAL/SHM 句柄释放可能延迟,切回 DELETE 失败时仍继续关闭连接。
|
||
}
|
||
|
||
db.close();
|
||
forceBunGc();
|
||
sleepSync(100);
|
||
}
|
||
|
||
export function configureSqliteForTest(db: Database): void {
|
||
// 生产库使用 WAL;测试库切回 DELETE,避免 Windows 上 WAL/SHM 句柄延迟导致清理 EBUSY。
|
||
db.exec("PRAGMA journal_mode = DELETE");
|
||
}
|
||
|
||
export function createMigratedMemoryTestDatabase(prefix: string): TestDatabaseHandle {
|
||
const dir = makeTempDir(prefix);
|
||
const logger = createMemoryLogger();
|
||
const db = new Database(":memory:");
|
||
configureSqliteForTest(db);
|
||
let closed = false;
|
||
|
||
runMigrations(db, loadMigrationsFromDir(), dir, logger);
|
||
|
||
const close = () => {
|
||
if (closed) return;
|
||
closeSqliteForTest(db);
|
||
closed = true;
|
||
};
|
||
|
||
return {
|
||
cleanup: () => {
|
||
close();
|
||
rmRetrySync(dir);
|
||
},
|
||
close,
|
||
db,
|
||
dir,
|
||
logger,
|
||
};
|
||
}
|
||
|
||
export function createMigratedTestDatabase(prefix: string): TestDatabaseHandle {
|
||
return createTestDatabase(prefix, loadMigrationsFromDir());
|
||
}
|
||
|
||
export function createTestDatabase(prefix: string, migrations: MigrationRecord[] = []): TestDatabaseHandle {
|
||
const dir = makeTempDir(prefix);
|
||
const logger = createMemoryLogger();
|
||
const db = createDatabase(dir, logger);
|
||
configureSqliteForTest(db);
|
||
let closed = false;
|
||
|
||
if (migrations.length > 0) {
|
||
runMigrations(db, migrations, dir, logger);
|
||
}
|
||
|
||
const close = () => {
|
||
if (closed) return;
|
||
closeSqliteForTest(db);
|
||
closed = true;
|
||
};
|
||
|
||
return {
|
||
cleanup: () => {
|
||
close();
|
||
rmRetrySync(dir);
|
||
},
|
||
close,
|
||
db,
|
||
dir,
|
||
logger,
|
||
};
|
||
}
|
||
|
||
export function makeTempDir(prefix: string): string {
|
||
const dir = join(tmpdir(), `${prefix}-${randomUUID()}`);
|
||
mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
export function openTestDatabase(dir: string): Pick<TestDatabaseHandle, "close" | "db" | "logger"> {
|
||
const logger = createMemoryLogger();
|
||
const db = createDatabase(dir, logger);
|
||
configureSqliteForTest(db);
|
||
let closed = false;
|
||
return {
|
||
close: () => {
|
||
if (closed) return;
|
||
closeSqliteForTest(db);
|
||
closed = true;
|
||
},
|
||
db,
|
||
logger,
|
||
};
|
||
}
|
||
|
||
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
await rm(dir, { force: true, recursive: true });
|
||
return;
|
||
} catch (e) {
|
||
const code = getErrorCode(e);
|
||
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
|
||
throw withCleanupMessage(e, dir, i + 1);
|
||
}
|
||
await new Promise((r) => setTimeout(r, delayMs));
|
||
}
|
||
}
|
||
}
|
||
|
||
export function rmRetrySync(dir: string, retries = 300, delayMs = 50): void {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
forceBunGc();
|
||
rmSync(dir, { force: true, recursive: true });
|
||
return;
|
||
} catch (e) {
|
||
const code = getErrorCode(e);
|
||
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
|
||
throw withCleanupMessage(e, dir, i + 1);
|
||
}
|
||
sleepSync(delayMs);
|
||
}
|
||
}
|
||
}
|
||
|
||
function forceBunGc(): void {
|
||
const bun = (globalThis as typeof globalThis & { Bun?: { gc?: (force?: boolean) => void } }).Bun;
|
||
bun?.gc?.(true);
|
||
}
|
||
|
||
function getErrorCode(error: unknown): string | undefined {
|
||
return typeof error === "object" && error !== null && "code" in error
|
||
? String((error as { code?: unknown }).code)
|
||
: undefined;
|
||
}
|
||
|
||
function sleepSync(ms: number): void {
|
||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||
}
|
||
|
||
function withCleanupMessage(error: unknown, dir: string, attempts: number): Error {
|
||
if (!(error instanceof Error)) {
|
||
return new Error(`测试临时目录清理失败:${dir},已重试 ${attempts} 次,原始错误:${String(error)}`);
|
||
}
|
||
|
||
error.message = `测试临时目录清理失败:${dir},已重试 ${attempts} 次。${error.message}`;
|
||
return error;
|
||
}
|