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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user