diff --git a/apps/api/release/app.js b/apps/api/release/app.js index 3406e71..9e40058 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -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 }; diff --git a/apps/api/src/prisma/migrate.spec.ts b/apps/api/src/prisma/migrate.spec.ts index 3933a62..5b251c6 100644 --- a/apps/api/src/prisma/migrate.spec.ts +++ b/apps/api/src/prisma/migrate.spec.ts @@ -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', () => { diff --git a/apps/api/src/prisma/migrate.ts b/apps/api/src/prisma/migrate.ts index b989d3b..acc2e18 100644 --- a/apps/api/src/prisma/migrate.ts +++ b/apps/api/src/prisma/migrate.ts @@ -171,6 +171,82 @@ export function sortMigrations(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 { + 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 { + 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, +): Promise { + 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 { const dir = resolveMigrationsDir(); const files = discoverMigrationFiles(dir); @@ -196,11 +272,33 @@ export async function runMigrations(prisma: PrismaClient): Promise 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 { - 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); }