feat: auto-run SQL migrations and one-time seed on boot
This commit is contained in:
@@ -2,9 +2,12 @@ import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { prepareDatabase } from './prisma/bootstrap';
|
||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
await prepareDatabase();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user