/** * DB migration runner for the manual SQL files in prisma/migrations/. * * Why a custom runner: the database is shared with the Arcturus emulator, so we * apply CMS-only schema changes via hand-written idempotent SQL (every file is * `IF NOT EXISTS`-guarded) instead of `prisma migrate dev`, whose auto-diff * would try to DROP emulator-owned tables. An `_applied_migrations` audit table * tracks what ran so the workflow doesn't rely on memory. * * pnpm db:migrate run pending migrations * pnpm db:migrate:status print pending vs applied * pnpm db:migrate --mark mark an already-applied file without running it */ import "dotenv/config"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { prisma } from "@/lib/prisma"; const MIGRATIONS_DIR = join(process.cwd(), "prisma", "migrations"); async function ensureAuditTable(): Promise { await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS _applied_migrations ( filename VARCHAR(255) NOT NULL, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), sha256 CHAR(64) NULL, PRIMARY KEY (filename) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } async function appliedSet(): Promise> { const rows = await prisma.$queryRawUnsafe<{ filename: string }[]>( "SELECT filename FROM _applied_migrations", ); return new Set(rows.map((r) => r.filename)); } async function listMigrations(): Promise { const files = await readdir(MIGRATIONS_DIR); return files.filter((f) => f.endsWith(".sql")).sort(); } async function sha256(filename: string): Promise { const content = await readFile(join(MIGRATIONS_DIR, filename)); return createHash("sha256").update(content).digest("hex"); } function runPrismaDbExecute(filename: string): boolean { const file = join(MIGRATIONS_DIR, filename); // shell:true so `npx` resolves to npx.cmd on Windows (direct .cmd spawn is // blocked there since CVE-2024-27980). Quote the path for spaces. const result = spawnSync("npx", ["prisma", "db", "execute", "--file", `"${file}"`], { stdio: "inherit", env: process.env, shell: true, }); return result.status === 0; } async function recordApplied(filename: string): Promise { const hash = await sha256(filename); await prisma.$executeRawUnsafe( "INSERT INTO _applied_migrations (filename, sha256) VALUES (?, ?) " + "ON DUPLICATE KEY UPDATE applied_at = CURRENT_TIMESTAMP(3), sha256 = VALUES(sha256)", filename, hash, ); } async function statusOnly(): Promise { await ensureAuditTable(); const applied = await appliedSet(); const files = await listMigrations(); if (files.length === 0) { console.log("No migrations found in prisma/migrations/."); return; } console.log("Status:"); for (const f of files) { const isApplied = applied.has(f); console.log(` ${isApplied ? "OK" : " ."} ${f}${isApplied ? "" : " (PENDING)"}`); } const pending = files.filter((f) => !applied.has(f)); console.log(`\n${pending.length} pending / ${files.length} total`); } async function markApplied(filename: string): Promise { await ensureAuditTable(); const files = await listMigrations(); if (!files.includes(filename)) { console.error(`File not found in prisma/migrations/: ${filename}`); process.exit(1); } await recordApplied(filename); console.log(`Marked ${filename} as applied (no SQL was run).`); } async function applyPending(): Promise { await ensureAuditTable(); const applied = await appliedSet(); const files = await listMigrations(); if (files.length === 0) { console.log("No migrations found in prisma/migrations/."); return; } let ran = 0; for (const f of files) { if (applied.has(f)) { console.log(`OK ${f} (already applied)`); continue; } console.log(`-> applying ${f} ...`); if (!runPrismaDbExecute(f)) { console.error(`FAILED ${f}; aborting`); process.exit(1); } await recordApplied(f); ran++; } console.log(ran === 0 ? "\nNothing to do — DB already in sync." : `\nApplied ${ran} migration(s).`); } async function main(): Promise { const args = process.argv.slice(2); try { if (args[0] === "--status") { await statusOnly(); } else if (args[0] === "--mark") { const filename = args[1]; if (!filename) { console.error("Usage: pnpm db:migrate --mark "); process.exit(1); } await markApplied(filename); } else { await applyPending(); } } finally { await prisma.$disconnect(); } } main().catch((e) => { console.error("FAILED:", e instanceof Error ? e.message : e); process.exit(1); });