import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, } from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; export interface DisposableDatabase { databaseUrl: string; databasePath: string; directory: string; } function pathFromDatabaseUrl(databaseUrl: string, cwd = process.cwd()) { if (!databaseUrl.startsWith("file:")) { throw new Error("Tests require a file: SQLite DATABASE_URL"); } const withoutScheme = databaseUrl.slice("file:".length).split("?")[0]; return path.resolve(cwd, withoutScheme); } export function assertDisposableTestDatabase(databaseUrl: string) { const databasePath = pathFromDatabaseUrl(databaseUrl); const normalized = databasePath.replaceAll("\\", "/").toLowerCase(); if ( normalized.endsWith("/dev.db") || normalized === "/app/data/study.db" || existsSync(databasePath) || !path.basename(databasePath).includes(".test.") ) { throw new Error(`Refusing to use non-disposable database: ${databasePath}`); } return databasePath; } export function createDisposableDatabase(): DisposableDatabase { const directory = path.join( process.cwd(), ".test-databases", `study-desk-tests-${randomUUID()}` ); mkdirSync(directory, { recursive: true }); const databasePath = path.join(directory, `${randomUUID()}.test.db`); const relativePath = path.relative(process.cwd(), databasePath).replaceAll("\\", "/"); const databaseUrl = `file:./${relativePath}`; assertDisposableTestDatabase(databaseUrl); return { databaseUrl, databasePath, directory }; } export function applyCommittedMigrations( database: DisposableDatabase, options: { exclude?: string[]; through?: string } = {} ) { assertDisposableTestDatabase(database.databaseUrl); const migrationRoot = path.join(process.cwd(), "prisma", "migrations"); const migrationNames = readdirSync(migrationRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((name) => !(options.exclude ?? []).includes(name)) .filter((name) => !options.through || name <= options.through) .sort(); const client = new Database(database.databasePath); try { client.pragma("foreign_keys = ON"); client.exec(` CREATE TABLE "_prisma_migrations" ( "id" TEXT NOT NULL PRIMARY KEY, "checksum" TEXT NOT NULL, "finished_at" DATETIME, "migration_name" TEXT NOT NULL, "logs" TEXT, "rolled_back_at" DATETIME, "started_at" DATETIME NOT NULL DEFAULT current_timestamp, "applied_steps_count" INTEGER NOT NULL DEFAULT 0 ); `); const recordMigration = client.prepare(` INSERT INTO "_prisma_migrations" ("id", "checksum", "finished_at", "migration_name", "applied_steps_count") VALUES (?, ?, current_timestamp, ?, 1) `); for (const migrationName of migrationNames) { const sql = readFileSync( path.join(migrationRoot, migrationName, "migration.sql"), "utf8" ); client.transaction(() => { client.exec(sql); recordMigration.run( randomUUID(), createHash("sha256").update(sql).digest("hex"), migrationName ); })(); } } finally { client.close(); } } export function disposeDisposableDatabase(database: DisposableDatabase) { rmSync(database.directory, { recursive: true, force: true }); }