fix(api): skip baseline replay when schema already exists

Dokploy boots crash-looped on CREATE TYPE AdminRole against an existing
Postgres with an empty _goldbank_migrations table. Adopt Prisma CLI history
when present, record the baseline if AdminRole exists, and treat duplicate
object errors as already-applied so the API can start without DB shell access.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-10-27 17:27:52 +03:30
co-authored by Cursor
parent 37d22d2f7d
commit b05b6515f2
3 changed files with 221 additions and 26 deletions
+75 -11
View File
@@ -164102,6 +164102,7 @@ var require_migrate = __commonJS({
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.splitSqlStatements = splitSqlStatements;
exports2.sortMigrations = sortMigrations;
exports2.isAlreadyExistsPgError = isAlreadyExistsPgError;
exports2.runMigrations = runMigrations;
exports2.runOnce = runOnce;
var fs_1 = require("fs");
@@ -164212,6 +164213,50 @@ var require_migrate = __commonJS({
return a.name.localeCompare(b.name);
});
}
function isAlreadyExistsPgError(err) {
const meta = err && typeof err === "object" && "meta" in err ? err.meta : void 0;
const code = String(meta?.code ?? "");
if (code === "42710" || code === "42P07" || code === "42701")
return true;
const msg = [
err instanceof Error ? err.message : String(err),
meta?.message ?? ""
].join(" ");
return /already exists/i.test(msg) || /duplicate_(object|table|column|relation)/i.test(msg);
}
async function typeExists(prisma, typname) {
const rows = await prisma.$queryRawUnsafe(`SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = $1 AND n.nspname = current_schema()
) AS "exists"`, typname);
return Boolean(rows[0]?.exists);
}
async function recordApplied(prisma, name) {
await prisma.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)
ON CONFLICT (name) DO NOTHING`, name);
}
async function adoptPrismaMigrationLedger(prisma, appliedNames) {
const table = await prisma.$queryRawUnsafe(`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema() AND table_name = '_prisma_migrations'
) AS "exists"`);
if (!table[0]?.exists)
return [];
const rows = await prisma.$queryRawUnsafe(`SELECT migration_name FROM "_prisma_migrations"
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`);
const adopted = [];
for (const row of rows) {
const name = row.migration_name;
if (!name || appliedNames.has(name))
continue;
await recordApplied(prisma, name);
appliedNames.add(name);
adopted.push(name);
}
return adopted;
}
async function runMigrations(prisma) {
const dir = resolveMigrationsDir();
const files = discoverMigrationFiles(dir);
@@ -164226,26 +164271,45 @@ var require_migrate = __commonJS({
)`);
const alreadyApplied = await prisma.$queryRawUnsafe(`SELECT name FROM "${TRACKING_TABLE}"`);
const appliedNames = new Set(alreadyApplied.map((row) => row.name));
const adopted = await adoptPrismaMigrationLedger(prisma, appliedNames);
if (adopted.length > 0) {
console.warn(`[migrate] Adopted ${adopted.length} migration(s) from _prisma_migrations: ${adopted.join(", ")}`);
}
const applied = [];
for (const file2 of files) {
const legacyName = LEGACY_NAME_ALIASES[file2.name];
if (appliedNames.has(file2.name) || legacyName && appliedNames.has(legacyName))
continue;
if (file2.name === BASELINE_MIGRATION && await typeExists(prisma, "AdminRole")) {
await recordApplied(prisma, file2.name);
appliedNames.add(file2.name);
applied.push(file2.name);
console.warn(`[migrate] Baseline ${file2.name} already present in schema (AdminRole); recorded without re-running SQL`);
continue;
}
const statements = splitSqlStatements((0, fs_1.readFileSync)(file2.path, "utf8")).filter((statement) => {
const normalized = statement.replace(/\s+/g, " ").trim().toLowerCase();
return !normalized.startsWith(`drop table "${TRACKING_TABLE.toLowerCase()}"`) && !normalized.startsWith(`drop table ${TRACKING_TABLE.toLowerCase()}`);
});
await prisma.$transaction(
async (tx) => {
for (const statement of statements) {
await tx.$executeRawUnsafe(statement);
}
await tx.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, file2.name);
},
// The baseline migration is hundreds of DDL statements; the 5s default is
// not enough, especially against a remote database.
{ maxWait: 6e4, timeout: 6e5 }
);
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)`, file2.name);
},
// The baseline migration is hundreds of DDL statements; the 5s default is
// not enough, especially against a remote database.
{ maxWait: 6e4, timeout: 6e5 }
);
} catch (err) {
if (!isAlreadyExistsPgError(err))
throw err;
await recordApplied(prisma, file2.name);
console.warn(`[migrate] ${file2.name}: objects already exist \u2014 recorded as applied (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`);
}
appliedNames.add(file2.name);
applied.push(file2.name);
}
return { freshDatabase, applied };
+18 -1
View File
@@ -1,6 +1,23 @@
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { sortMigrations, splitSqlStatements } from './migrate';
import { isAlreadyExistsPgError, sortMigrations, splitSqlStatements } from './migrate';
describe('isAlreadyExistsPgError', () => {
it('recognises Postgres duplicate_object / duplicate_table codes from Prisma meta', () => {
expect(isAlreadyExistsPgError({ meta: { code: '42710' } })).toBe(true);
expect(isAlreadyExistsPgError({ meta: { code: '42P07' } })).toBe(true);
expect(isAlreadyExistsPgError({ meta: { code: '42701' } })).toBe(true);
expect(isAlreadyExistsPgError({ meta: { code: '23505' } })).toBe(false);
});
it('recognises the baseline crash message from a live Dokploy boot', () => {
expect(
isAlreadyExistsPgError(
new Error('Raw query failed. Code: `42710`. Message: `type "AdminRole" already exists`'),
),
).toBe(true);
});
});
describe('sortMigrations', () => {
it('puts the baseline before 0002 so an empty database can boot', () => {
+128 -14
View File
@@ -171,6 +171,82 @@ export function sortMigrations<T extends { name: string }>(files: T[]): T[] {
});
}
/**
* Postgres / Prisma "object already there" failures from a raw DDL statement.
* Used when the live schema was created outside `_goldbank_migrations`
* (prisma migrate, db push, or a previous half-boot) so we can record the
* migration as applied instead of crash-looping the container.
*/
export function isAlreadyExistsPgError(err: unknown): boolean {
const meta =
err && typeof err === 'object' && 'meta' in err
? (err as { meta?: { code?: string; message?: string } }).meta
: undefined;
const code = String(meta?.code ?? '');
if (code === '42710' || code === '42P07' || code === '42701') return true;
const msg = [
err instanceof Error ? err.message : String(err),
meta?.message ?? '',
].join(' ');
return /already exists/i.test(msg)
|| /duplicate_(object|table|column|relation)/i.test(msg);
}
async function typeExists(prisma: PrismaClient, typname: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(
`SELECT EXISTS (
SELECT 1
FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = $1 AND n.nspname = current_schema()
) AS "exists"`,
typname,
);
return Boolean(rows[0]?.exists);
}
async function recordApplied(prisma: PrismaClient, name: string): Promise<void> {
await prisma.$executeRawUnsafe(
`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)
ON CONFLICT (name) DO NOTHING`,
name,
);
}
/**
* Databases that previously used Prisma CLI keep their history in
* `_prisma_migrations`. Copy those names into our tracking table so the
* runtime runner does not replay SQL that already ran.
*/
async function adoptPrismaMigrationLedger(
prisma: PrismaClient,
appliedNames: Set<string>,
): Promise<string[]> {
const table = await prisma.$queryRawUnsafe<{ exists: boolean }[]>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema() AND table_name = '_prisma_migrations'
) AS "exists"`,
);
if (!table[0]?.exists) return [];
const rows = await prisma.$queryRawUnsafe<{ migration_name: string }[]>(
`SELECT migration_name FROM "_prisma_migrations"
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
);
const adopted: string[] = [];
for (const row of rows) {
const name = row.migration_name;
if (!name || appliedNames.has(name)) continue;
await recordApplied(prisma, name);
appliedNames.add(name);
adopted.push(name);
}
return adopted;
}
export async function runMigrations(prisma: PrismaClient): Promise<MigrationResult> {
const dir = resolveMigrationsDir();
const files = discoverMigrationFiles(dir);
@@ -196,11 +272,33 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
);
const appliedNames = new Set(alreadyApplied.map((row) => row.name));
const adopted = await adoptPrismaMigrationLedger(prisma, appliedNames);
if (adopted.length > 0) {
// eslint-disable-next-line no-console
console.warn(
`[migrate] Adopted ${adopted.length} migration(s) from _prisma_migrations: ${adopted.join(', ')}`,
);
}
const applied: string[] = [];
for (const file of files) {
const legacyName = LEGACY_NAME_ALIASES[file.name];
if (appliedNames.has(file.name) || (legacyName && appliedNames.has(legacyName))) continue;
// Live DB already has the baseline schema (common when Dokploy points at an
// existing Postgres and `_goldbank_migrations` is empty). Replaying the
// non-idempotent CREATE TYPE/TABLE baseline crash-loops the API → nginx 502.
if (file.name === BASELINE_MIGRATION && (await typeExists(prisma, 'AdminRole'))) {
await recordApplied(prisma, file.name);
appliedNames.add(file.name);
applied.push(file.name);
// eslint-disable-next-line no-console
console.warn(
`[migrate] Baseline ${file.name} already present in schema (AdminRole); recorded without re-running SQL`,
);
continue;
}
const statements = splitSqlStatements(readFileSync(file.path, 'utf8')).filter(
(statement) => {
// Prisma migrate diff once emitted DROP TABLE "_goldbank_migrations"
@@ -211,20 +309,36 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
&& !normalized.startsWith(`drop table ${TRACKING_TABLE.toLowerCase()}`);
},
);
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 },
);
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)
})`,
);
}
appliedNames.add(file.name);
applied.push(file.name);
}