fix(api): survive existing-schema boots without DB shell

Nginx 502 Host-not-found meant the API never stayed listening. Stamp all
migrations when tracking is empty but a late table exists, apply DDL
statement-by-statement skipping already-exists, harden remaining CREATE
TYPE/TABLE files, and never let seed crash the process.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-10-27 17:35:25 +03:30
co-authored by Cursor
parent b05b6515f2
commit 15adbb7fe1
11 changed files with 202 additions and 169 deletions
+12 -5
View File
@@ -52,11 +52,18 @@ export async function prepareDatabase(): Promise<void> {
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');
try {
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');
} catch (err) {
// Seed must never crash-loop the API (nginx Host not found / 502).
logger.warn(
`Seed skipped after error: ${err instanceof Error ? err.message : String(err)}`,
);
}
} finally {
await prisma.$disconnect();
}
+52 -26
View File
@@ -206,6 +206,17 @@ async function typeExists(prisma: PrismaClient, typname: string): Promise<boolea
return Boolean(rows[0]?.exists);
}
async function tableExists(prisma: PrismaClient, tableName: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema() AND table_name = $1
) AS "exists"`,
tableName,
);
return Boolean(rows[0]?.exists);
}
async function recordApplied(prisma: PrismaClient, name: string): Promise<void> {
await prisma.$executeRawUnsafe(
`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)
@@ -281,6 +292,30 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
}
const applied: string[] = [];
// Empty goldbank ledger on a DB that already has a late table = previous
// prisma migrate / db push. Stamping every known file lets the API listen
// without replaying non-idempotent DDL (nginx 502 / Host not found loop).
// A later release that adds a new migration still runs it: appliedNames
// will be non-empty, so this shortcut does not fire again.
if (
appliedNames.size === 0
&& ((await tableExists(prisma, 'Admin'))
|| (await tableExists(prisma, 'AccountingOutbox'))
|| (await tableExists(prisma, 'AdminSession')))
) {
for (const file of files) {
await recordApplied(prisma, file.name);
appliedNames.add(file.name);
applied.push(file.name);
}
// eslint-disable-next-line no-console
console.warn(
`[migrate] Empty tracking on existing schema; recorded ${applied.length} migration(s) without SQL`,
);
return { freshDatabase, applied };
}
for (const file of files) {
const legacyName = LEGACY_NAME_ALIASES[file.name];
if (appliedNames.has(file.name) || (legacyName && appliedNames.has(legacyName))) continue;
@@ -310,33 +345,24 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
},
);
try {
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.name,
);
},
// 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 },
);
} catch (err) {
if (!isAlreadyExistsPgError(err)) throw err;
// Schema already matches this migration (e.g. prior db push). Record and
// continue so the container can boot without shell access to Postgres.
await recordApplied(prisma, file.name);
// eslint-disable-next-line no-console
console.warn(
`[migrate] ${file.name}: objects already exist — recorded as applied (${
err instanceof Error ? err.message.split('\n')[0] : String(err)
})`,
);
// Apply one statement at a time (not one big transaction). A prior db push
// often leaves some objects present and others missing; failing the whole
// file on the first "already exists" would skip the rest and still crash
// on a later non-duplicate error — or mark the file applied too early.
for (const statement of statements) {
try {
await prisma.$executeRawUnsafe(statement);
} catch (err) {
if (!isAlreadyExistsPgError(err)) throw err;
// eslint-disable-next-line no-console
console.warn(
`[migrate] ${file.name}: skipped already-existing object (${
err instanceof Error ? err.message.split('\n')[0] : String(err)
})`,
);
}
}
await recordApplied(prisma, file.name);
appliedNames.add(file.name);
applied.push(file.name);