test(db): apply migrations to a populated v0.2.0 database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:48:53 -04:00
1 parent c6e414ecdb
commit dcaf509160
3 files changed
+202

No files matched your search

+19
View File
@@ -1,7 +1,9 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { readMigrationFiles } from "drizzle-orm/migrator";
import { createLogger } from "@sofa/logger";
@@ -18,6 +20,23 @@ export function runMigrations(migrationsFolder = getMigrationsFolder()) {
log.debug("Database migrations complete");
}
/**
* True when an existing database has migrations to apply. A brand-new database (no
* migrations table yet) returns false — there's nothing to back up.
*/
export function hasPendingMigrations(migrationsFolder = getMigrationsFolder()): boolean {
const table = db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'`,
);
if (table.length === 0) return false;
const columns = db.all<{ name: string }>(sql`PRAGMA table_info(__drizzle_migrations)`);
if (!columns.some((c) => c.name === "name")) return true; // legacy table; drizzle will upgrade it
const applied = new Set(
db.all<{ name: string | null }>(sql`SELECT name FROM __drizzle_migrations`).map((r) => r.name),
);
return readMigrationFiles({ migrationsFolder }).some((m) => !applied.has(m.name));
}
/**
* Apply pending migrations to a SQLite file other than the live database (e.g. a backup
* about to be restored). Throws — leaving the live database untouched — if any fails.
+145
View File
@@ -0,0 +1,145 @@
import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, describe, expect, test } from "vitest";
const MIGRATIONS_DIR = fileURLToPath(new URL("../drizzle", import.meta.url));
// Last migration shipped in the v0.2.0 release.
const V0_2_0_LAST_MIGRATION = "20260324223903_secret_rhodey";
const tempDirs: string[] = [];
const clients: Database.Database[] = [];
afterEach(() => {
for (const client of clients.splice(0)) client.close();
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function migrationFolders(): string[] {
return readdirSync(MIGRATIONS_DIR)
.filter((name) => existsSync(path.join(MIGRATIONS_DIR, name, "migration.sql")))
.sort();
}
function createDbAt(lastMigration: string) {
const dir = mkdtempSync(path.join(os.tmpdir(), "sofa-migrations-"));
tempDirs.push(dir);
for (const name of migrationFolders().filter((n) => n <= lastMigration)) {
cpSync(path.join(MIGRATIONS_DIR, name, ""), path.join(dir, name), { recursive: true });
}
const client = new Database(":memory:");
clients.push(client);
client.pragma("foreign_keys = ON");
const db = drizzle({ client });
migrate(db, { migrationsFolder: dir });
return { client, db };
}
const T = 1700000000;
function seedV020(client: Database.Database) {
client.exec(`
INSERT INTO "user" (id, name, email, emailVerified, createdAt, updatedAt) VALUES
('u1', 'Alice', 'alice@example.com', 1, ${T}, ${T}),
('u2', 'Bob', 'bob@example.com', 0, ${T}, ${T});
INSERT INTO titles (id, tmdbId, type, title) VALUES
('t-movie-a', 100, 'movie', 'Movie A'),
('t-show', 200, 'tv', 'Show'),
('t-movie-b', 300, 'movie', 'Movie B');
INSERT INTO seasons (id, titleId, seasonNumber) VALUES ('s1', 't-show', 1);
INSERT INTO episodes (id, seasonId, episodeNumber) VALUES ('e1', 's1', 1), ('e2', 's1', 2);
INSERT INTO userTitleStatus (userId, titleId, status, addedAt, updatedAt) VALUES
('u1', 't-show', 'completed', ${T}, ${T}),
('u1', 't-movie-a', 'in_progress', ${T}, ${T}),
('u1', 't-movie-b', 'in_progress', ${T}, ${T});
INSERT INTO userMovieWatches (id, userId, titleId, watchedAt) VALUES ('w1', 'u1', 't-movie-a', ${T});
INSERT INTO userEpisodeWatches (id, userId, episodeId, watchedAt) VALUES ('ew1', 'u1', 'e1', ${T});
INSERT INTO userRatings (userId, titleId, ratingStars, ratedAt) VALUES ('u1', 't-movie-a', 4, ${T});
INSERT INTO persons (id, tmdbId, name) VALUES ('p1', 500, 'Person');
INSERT INTO titleCast (id, titleId, personId, character, department, job, displayOrder) VALUES
('c1', 't-movie-a', 'p1', NULL, 'Directing', 'Director', 0),
('c2', 't-movie-a', 'p1', NULL, 'Directing', 'Director', 0);
INSERT INTO platforms (id, name) VALUES ('pl1', 'Platform');
INSERT INTO platformTmdbIds (platformId, tmdbProviderId) VALUES ('pl1', 8);
INSERT INTO userPlatforms (userId, platformId) VALUES ('u1', 'pl1');
INSERT INTO titleAvailability (titleId, platformId, offerType, region) VALUES ('t-movie-a', 'pl1', 'flatrate', 'US');
INSERT INTO integrations (id, userId, provider, type, token, createdAt) VALUES
('i1', 'u1', 'plex', 'webhook', 'secret-token', ${T});
INSERT INTO importJobs (id, userId, source, status, payload, importWatches, importWatchlist, importRatings, createdAt) VALUES
('j1', 'u1', 'trakt', 'success', '{}', 1, 1, 1, ${T});
INSERT INTO appSettings (key, value) VALUES ('someSetting', 'someValue');
`);
}
const SEEDED_TABLES = [
"user",
"titles",
"seasons",
"episodes",
"userTitleStatus",
"userMovieWatches",
"userEpisodeWatches",
"userRatings",
"persons",
"platforms",
"platformTmdbIds",
"userPlatforms",
"titleAvailability",
"integrations",
"importJobs",
"appSettings",
];
function count(client: Database.Database, table: string): number {
return (client.prepare(`SELECT count(*) AS c FROM "${table}"`).get() as { c: number }).c;
}
describe("migrations on an older database", () => {
test("builds a v0.2.0-schema database", () => {
const { client } = createDbAt(V0_2_0_LAST_MIGRATION);
const expected = migrationFolders().filter((n) => n <= V0_2_0_LAST_MIGRATION).length;
expect(count(client, "__drizzle_migrations")).toBe(expected);
});
test("applies newer migrations to a populated v0.2.0 database", () => {
const { client, db } = createDbAt(V0_2_0_LAST_MIGRATION);
seedV020(client);
const before = Object.fromEntries(SEEDED_TABLES.map((t) => [t, count(client, t)]));
expect(count(client, "titleCast")).toBe(2);
expect(() => migrate(db, { migrationsFolder: MIGRATIONS_DIR })).not.toThrow();
expect(count(client, "__drizzle_migrations")).toBe(migrationFolders().length);
expect(client.pragma("foreign_key_check")).toEqual([]);
expect(client.pragma("integrity_check", { simple: true })).toBe("ok");
for (const table of SEEDED_TABLES) {
expect(count(client, table), table).toBe(before[table]);
}
expect(count(client, "titleCast")).toBe(1);
const statuses = client
.prepare("SELECT titleId, status FROM userTitleStatus")
.all() as { titleId: string; status: string }[];
const byTitle = Object.fromEntries(statuses.map((s) => [s.titleId, s.status]));
expect(byTitle).toEqual({
"t-show": "in_progress",
"t-movie-a": "completed",
"t-movie-b": "watchlist",
});
});
});
@@ -0,0 +1,38 @@
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import { testClient } from "@sofa/test/db";
import { hasPendingMigrations } from "../src/migrate";
const MIGRATIONS_DIR = fileURLToPath(new URL("../drizzle", import.meta.url));
type MigrationRow = { id: number; hash: string; created_at: number; name: string | null };
let removed: MigrationRow | undefined;
afterEach(() => {
if (removed) {
testClient
.prepare("INSERT INTO __drizzle_migrations (id, hash, created_at, name) VALUES (?, ?, ?, ?)")
.run(removed.id, removed.hash, removed.created_at, removed.name);
removed = undefined;
}
});
describe("hasPendingMigrations", () => {
test("is false when every migration is applied", () => {
expect(hasPendingMigrations(MIGRATIONS_DIR)).toBe(false);
});
test("is true when a migration has not been applied", () => {
removed = testClient
.prepare("SELECT id, hash, created_at, name FROM __drizzle_migrations ORDER BY id DESC LIMIT 1")
.get() as MigrationRow;
testClient.exec(
"DELETE FROM __drizzle_migrations WHERE id = (SELECT max(id) FROM __drizzle_migrations)",
);
expect(hasPendingMigrations(MIGRATIONS_DIR)).toBe(true);
});
});