Refactor Study Desk application structure

This commit is contained in:
Elijah 2026-08-07 19:31:23 -07:00
parent faaccf8a7e
commit 089439ed90
145 changed files with 8087 additions and 3412 deletions

View file

@ -0,0 +1,41 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { assertDisposableTestDatabase } from "./testDatabase";
const cleanup: string[] = [];
afterEach(() => {
for (const directory of cleanup.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("assertDisposableTestDatabase", () => {
it("accepts a new uniquely named test database", () => {
const directory = mkdtempSync(path.join(tmpdir(), "study-db-safety-"));
cleanup.push(directory);
const target = path.join(directory, "unique.test.db");
expect(assertDisposableTestDatabase(`file:${target}`)).toBe(path.resolve(target));
});
it.each(["file:./dev.db", "file:/app/data/study.db"])(
"rejects protected database path %s",
(databaseUrl) => {
expect(() => assertDisposableTestDatabase(databaseUrl)).toThrow(
"Refusing to use non-disposable database"
);
}
);
it("rejects an existing database even when its name looks like a test", () => {
const directory = mkdtempSync(path.join(tmpdir(), "study-db-safety-"));
cleanup.push(directory);
const target = path.join(directory, "existing.test.db");
writeFileSync(target, "not disposable");
expect(() => assertDisposableTestDatabase(`file:${target}`)).toThrow(
"Refusing to use non-disposable database"
);
});
});

View file

@ -0,0 +1,107 @@
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 });
}