import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { PrismaClient } from '@prisma/client'; const TRACKING_TABLE = '_goldbank_migrations'; /** * Applies the .sql files in prisma/migrations (copied to ./migrations in the * Docker release) and records them so each one runs once. * * The deploy image is copy-only and has no Prisma CLI, so `migrate deploy` is * not an option — this runs the same SQL through the client instead. */ export type MigrationResult = { /** True when the tracking table was created by this run (empty database). */ freshDatabase: boolean; applied: string[]; }; function resolveMigrationsDir(): string { const candidates = [ process.env.MIGRATIONS_DIR, join(process.cwd(), 'migrations'), join(__dirname, '..', '..', 'migrations'), join(__dirname, '..', '..', 'prisma', 'migrations'), join(process.cwd(), 'prisma', 'migrations'), ].filter((p): p is string => Boolean(p)); const found = candidates.find((dir) => existsSync(dir)); if (!found) { throw new Error( `پوشه migrations پیدا نشد. مسیرهای بررسی‌شده: ${candidates.join(', ')}`, ); } return found; } /** * Postgres rejects multiple statements in one parameterised query, so each file * is split first. Quoting rules matter: identifiers/strings may contain ';'. */ export function splitSqlStatements(sql: string): string[] { const statements: string[] = []; let current = ''; let i = 0; while (i < sql.length) { const char = sql[i]; const rest = sql.slice(i); if (rest.startsWith('--')) { const lineEnd = sql.indexOf('\n', i); i = lineEnd === -1 ? sql.length : lineEnd + 1; continue; } if (rest.startsWith('/*')) { const commentEnd = sql.indexOf('*/', i + 2); i = commentEnd === -1 ? sql.length : commentEnd + 2; continue; } if (char === "'" || char === '"') { const quote = char; current += char; i += 1; while (i < sql.length) { current += sql[i]; if (sql[i] === quote) { // Doubled quote is an escaped quote, not the end of the literal. if (sql[i + 1] === quote) { current += sql[i + 1]; i += 2; continue; } i += 1; break; } i += 1; } continue; } const dollarTag = /^\$[A-Za-z_]*\$/.exec(rest); if (dollarTag) { const tag = dollarTag[0]; const closing = sql.indexOf(tag, i + tag.length); const end = closing === -1 ? sql.length : closing + tag.length; current += sql.slice(i, end); i = end; continue; } if (char === ';') { if (current.trim()) statements.push(current.trim()); current = ''; i += 1; continue; } current += char; i += 1; } if (current.trim()) statements.push(current.trim()); return statements; } export async function runMigrations(prisma: PrismaClient): Promise { const dir = resolveMigrationsDir(); const files = readdirSync(dir) .filter((name) => name.endsWith('.sql')) .sort(); const trackingExisted = await prisma.$queryRawUnsafe<{ exists: boolean }[]>( `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1 ) AS "exists"`, TRACKING_TABLE, ); const freshDatabase = !trackingExisted[0]?.exists; await prisma.$executeRawUnsafe( `CREATE TABLE IF NOT EXISTS "${TRACKING_TABLE}" ( name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, ); const alreadyApplied = await prisma.$queryRawUnsafe<{ name: string }[]>( `SELECT name FROM "${TRACKING_TABLE}"`, ); const appliedNames = new Set(alreadyApplied.map((row) => row.name)); const applied: string[] = []; for (const file of files) { if (appliedNames.has(file)) continue; const statements = splitSqlStatements(readFileSync(join(dir, file), 'utf8')); await prisma.$transaction( async (tx) => { for (const statement of statements) { await tx.$executeRawUnsafe(statement); } await tx.$executeRawUnsafe( `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, file, ); }, // The baseline migration is hundreds of DDL statements; the 5s default is // not enough, especially against a remote database. { maxWait: 60_000, timeout: 600_000 }, ); applied.push(file); } return { freshDatabase, applied }; } /** Records a one-off task (e.g. seeding) in the same table so it never repeats. */ export async function runOnce( prisma: PrismaClient, name: string, task: () => Promise, ): Promise { const rows = await prisma.$queryRawUnsafe<{ name: string }[]>( `SELECT name FROM "${TRACKING_TABLE}" WHERE name = $1`, name, ); if (rows.length > 0) return false; await task(); await prisma.$executeRawUnsafe( `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, name, ); return true; }