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