diff --git a/packages/db/src/migrations/0001_initial.ts b/packages/db/src/migrations/0001_initial.ts index ad3fdec..5d4f13a 100644 --- a/packages/db/src/migrations/0001_initial.ts +++ b/packages/db/src/migrations/0001_initial.ts @@ -389,4 +389,37 @@ export const initial: Migration = { { indexes: [["status", "run_at"], ["lock_key"]] }, ); }, + + async down(db: Kysely) { + for (const table of [ + "jobs", + "change_events", + "source_record_versions", + "source_records", + "source_runs", + "match_suggestions", + "user_history", + "attachment_links", + "attachments", + "copies", + "custom_field_definitions", + "list_items", + "lists", + "listens", + "engagement_sources", + "source_connections", + "engagements", + "entries", + "work_history", + "work_aliases", + "external_ids", + "works", + "app_tokens", + "invites", + "sessions", + "users", + ]) { + await db.schema.dropTable(table).execute(); + } + }, }; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index f0be17e..81c27bf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -5,6 +5,7 @@ */ export type Role = "admin" | "member"; +export type EntryStatus = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none"; export type Visibility = "private" | "friends" | "public"; export type TokenScope = "read" | "write" | "ingest"; export type CustomFieldLevel = "entry" | "engagement" | "copy"; @@ -105,7 +106,7 @@ export interface EntriesTable { id: string; user_id: string; work_id: string; - status: string; + status: EntryStatus; rating: number | null; review: string | null; private_notes: string | null; diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts index c8dade8..651c261 100644 --- a/packages/db/src/testing.ts +++ b/packages/db/src/testing.ts @@ -1,5 +1,5 @@ import { SQL } from "bun"; -import { afterAll, afterEach, beforeEach, describe } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe } from "bun:test"; import { createDb, type Db } from "./db.ts"; import type { Dialect } from "./dialect.ts"; import { migrateToLatest } from "./migrate.ts"; @@ -17,33 +17,50 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi for (const dialect of dialects) { describe(`${name} [${dialect}]`, () => { let current: Db | undefined; - const created: string[] = []; + let currentName: string | undefined; + const template = `st_tpl_${crypto.randomUUID().replaceAll("-", "")}`; + + if (dialect === "postgres") { + // Migrate one template per describe block, then clone it per test (much faster). + beforeAll(async () => { + await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${template}"`)); + const tpl = await createDb(urlFor(pgUrl!, template)); + try { + await migrateToLatest(tpl); + } finally { + await tpl.close(); + } + }, HOOK_TIMEOUT_MS); + } beforeEach(async () => { - let url = "sqlite://:memory:"; if (dialect === "postgres") { - const dbName = `st_${crypto.randomUUID().replaceAll("-", "")}`; - await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${dbName}"`)); - created.push(dbName); - const u = new URL(pgUrl!); - u.pathname = `/${dbName}`; - url = u.toString(); + currentName = `st_${crypto.randomUUID().replaceAll("-", "")}`; + await withAdmin(pgUrl!, (admin) => + admin.unsafe(`create database "${currentName}" template "${template}"`), + ); + current = await createDb(urlFor(pgUrl!, currentName)); + } else { + current = await createDb("sqlite://:memory:"); + await migrateToLatest(current); } - current = await createDb(url); - await migrateToLatest(current); - }); + }, HOOK_TIMEOUT_MS); afterEach(async () => { await current?.close(); current = undefined; - }); + if (currentName) { + const dbName = currentName; + currentName = undefined; + await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${dbName}" with (force)`)); + } + }, HOOK_TIMEOUT_MS); - afterAll(async () => { - if (created.length === 0) return; - await withAdmin(pgUrl!, async (admin) => { - for (const dbName of created) await admin.unsafe(`drop database if exists "${dbName}" with (force)`); - }); - }); + if (dialect === "postgres") { + afterAll(async () => { + await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${template}" with (force)`)); + }, HOOK_TIMEOUT_MS); + } fn(() => { if (!current) throw new Error("getDb() called outside a test"); @@ -53,6 +70,14 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi } } +const HOOK_TIMEOUT_MS = 30_000; + +function urlFor(base: string, dbName: string): string { + const u = new URL(base); + u.pathname = `/${dbName}`; + return u.toString(); +} + async function withAdmin(url: string, f: (admin: SQL) => Promise): Promise { const admin = new SQL(url); try { diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index 96dadd7..6bef1c2 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; +import { sql, type Kysely } from "kysely"; +import { Migrator } from "kysely/migration"; import { newId, nowIso } from "../src/ids.ts"; import { toJson } from "../src/json.ts"; +import { migrations } from "../src/migrations/index.ts"; import { forEachDialect } from "../src/testing.ts"; forEachDialect("schema", (getDb) => { @@ -57,7 +60,7 @@ forEachDialect("schema", (getDb) => { const entry = { user_id: userId, work_id: workId, - status: "planned", + status: "planned" as const, rating: null, review: null, private_notes: null, @@ -75,4 +78,13 @@ forEachDialect("schema", (getDb) => { await k.insertInto("entries").values({ id: newId(), ...entry }).execute(); await expect(k.insertInto("entries").values({ id: newId(), ...entry }).execute()).rejects.toThrow(); }); + + test("migrating down removes the tables", async () => { + const k = getDb().k as Kysely; + const migrator = new Migrator({ db: k, provider: { getMigrations: async () => migrations } }); + const { error } = await migrator.migrateDown(); + expect(error).toBeUndefined(); + const probe = sql`select 1 from users`.execute(k); + await expect(probe).rejects.toThrow(); + }); });