mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
3 files changed
+202
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user