feat: auto-run SQL migrations and one-time seed on boot

This commit is contained in:
mahmood
2026-09-10 17:15:17 +03:30
parent a4c5c9259f
commit f19f49daeb
12 changed files with 2569 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { execFile } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { promisify } from 'util';
import { Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { runMigrations, runOnce } from './migrate';
const execFileAsync = promisify(execFile);
function resolveSeedScript(): string | undefined {
const candidates = [
process.env.SEED_SCRIPT,
join(process.cwd(), 'seed.js'),
join(__dirname, '..', '..', 'seed.js'),
].filter((p): p is string => Boolean(p));
return candidates.find((file) => existsSync(file));
}
/**
* Runs pending SQL migrations before Nest boots, then seeds once.
*
* Seeding is gated on a marker row written only after the seed succeeds, so an
* interrupted seed is retried on the next boot (the seed itself upserts).
* Set RUN_MIGRATIONS=false or RUN_SEED=false to skip.
*/
export async function prepareDatabase(): Promise<void> {
if (process.env.RUN_MIGRATIONS === 'false') return;
const logger = new Logger('DatabaseBootstrap');
const prisma = new PrismaClient();
try {
const { applied } = await runMigrations(prisma);
if (applied.length > 0) {
logger.log(`Applied migrations: ${applied.join(', ')}`);
} else {
logger.log('No pending migrations');
}
if (process.env.RUN_SEED === 'false') return;
const seedScript = resolveSeedScript();
if (!seedScript) {
logger.warn('No seed script found — skipping seed');
return;
}
const seeded = await runOnce(prisma, 'seed:initial', async () => {
const { stdout } = await execFileAsync(process.execPath, [seedScript]);
if (stdout.trim()) logger.log(stdout.trim());
});
if (seeded) logger.log('Seed data inserted');
} finally {
await prisma.$disconnect();
}
}
+178
View File
@@ -0,0 +1,178 @@
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;
}