179 lines
5.0 KiB
TypeScript
179 lines
5.0 KiB
TypeScript
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<MigrationResult> {
|
||
|
|
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<void>,
|
||
|
|
): Promise<boolean> {
|
||
|
|
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;
|
||
|
|
}
|