import Database from "bun:sqlite"; 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; } 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}-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); return dir; } export function openTestDatabase(dir: string): Pick { 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; }