fix(db): add down migration, template-DB test harness, EntryStatus type

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:49:06 +01:00
co-authored by Claude Sonnet 5.5
parent da04adb286
commit ff595c1c5f
4 changed files with 92 additions and 21 deletions
@@ -389,4 +389,37 @@ export const initial: Migration = {
{ indexes: [["status", "run_at"], ["lock_key"]] },
);
},
async down(db: Kysely<any>) {
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();
}
},
};
+2 -1
View File
@@ -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;
+44 -19
View File
@@ -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<T>(url: string, f: (admin: SQL) => Promise<T>): Promise<T> {
const admin = new SQL(url);
try {
+13 -1
View File
@@ -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<any>;
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();
});
});