UNPKG

prisma-migrations

Version:

A Node.js library to manage Prisma ORM migrations like other ORMs

829 lines (818 loc) 27.4 kB
// src/migrations/index.ts import { readdir, readFile as readFile2, access } from "fs/promises"; import { join } from "path"; // src/logger/index.ts var LOG_LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4, trace: 5 }; class Logger { currentLevel; constructor(level = "silent") { this.currentLevel = level; } set level(level) { this.currentLevel = level; } shouldLog(level) { return LOG_LEVELS[level] <= LOG_LEVELS[this.currentLevel]; } log(level, message) { if (!this.shouldLog(level)) return; const timestamp = new Date().toLocaleTimeString(); const prefix = `[${timestamp}] [${level.toUpperCase()}]`; if (message instanceof Error) { console.error(prefix, message.message); if (this.currentLevel === "debug" || this.currentLevel === "trace") { console.error(message.stack); } } else { console.log(prefix, message); } } trace(message) { this.log("trace", message); } debug(message) { this.log("debug", message); } info(message) { this.log("info", message); } warn(message) { this.log("warn", message); } error(message) { this.log("error", message); } } var logLevel = process.env.PRISMA_MIGRATIONS_LOG_LEVEL || "silent"; var logger = new Logger(logLevel); // src/utils/index.ts import { createHash } from "crypto"; import { readFile } from "fs/promises"; // src/utils/colors.ts var reset = "\x1B[0m"; var bold = "\x1B[1m"; var red = "\x1B[31m"; var green = "\x1B[32m"; var yellow = "\x1B[33m"; var blue = "\x1B[34m"; var cyan = "\x1B[36m"; var gray = "\x1B[90m"; var colors = { reset: (str) => `${reset}${str}`, bold: (str) => `${bold}${str}${reset}`, red: (str) => `${red}${str}${reset}`, green: (str) => `${green}${str}${reset}`, yellow: (str) => `${yellow}${str}${reset}`, blue: (str) => `${blue}${str}${reset}`, cyan: (str) => `${cyan}${str}${reset}`, gray: (str) => `${gray}${str}${reset}` }; // src/utils/index.ts async function generateChecksum(filePath) { const content = await readFile(filePath, "utf-8"); return createHash("sha256").update(content).digest("hex"); } // src/errors/index.ts class MigrationError extends Error { suggestions; helpCommand; constructor(message, suggestions = [], helpCommand) { super(message); this.suggestions = suggestions; this.helpCommand = helpCommand; this.name = "MigrationError"; } format() { let output = ` ${colors.bold(colors.red(this.message))} `; if (this.suggestions.length > 0) { output += ` ${colors.cyan("Suggestions:")} `; this.suggestions.forEach((suggestion) => { output += ` ${colors.gray("•")} ${suggestion} `; }); } if (this.helpCommand) { output += ` ${colors.yellow("Need help?")} Run: ${colors.cyan(this.helpCommand)} `; } return output; } } function createMigrationNotFoundError(migrationId) { return new MigrationError(`Migration file not found for ${migrationId}`, [ "Run 'prisma-migrations status' to see all migrations", "Check if the migration directory exists in prisma/migrations", "The migration file may have been deleted or moved" ]); } function createInvalidMigrationError(migrationId, reason) { return new MigrationError(`Migration ${migrationId} is invalid: ${reason}`, [ "Check that migration.sql exists in the migration directory", "Ensure the file contains both '-- Migration: Up' and '-- Migration: Down' markers", "Verify the SQL syntax is correct" ]); } function createChecksumMismatchError(migrationId) { return new MigrationError(`Migration ${migrationId} has been modified after being applied`, [ "The migration file content has changed since it was applied", "Never modify migrations that have been applied to production", "Create a new migration instead to make schema changes", "If this is a development environment, you can run 'prisma-migrations fresh --force'" ]); } function createTransactionFailedError(migrationId, error) { return new MigrationError(`Transaction failed for migration ${migrationId}: ${error.message}`, [ "The migration was rolled back automatically", "No changes were applied to the database", "Check the SQL syntax and fix the migration", "Review the error message above for details" ]); } // src/migrations/locking.ts class MigrationLockError extends Error { isTimeout; constructor(message, isTimeout = false) { super(message); this.name = "MigrationLockError"; this.isTimeout = isTimeout; } } class MigrationLock { prisma; lockId; lockAcquired = false; databaseType = "unknown"; constructor(prisma) { this.prisma = prisma; this.lockId = 1836213295; } async detectDatabaseType() { if (this.databaseType !== "unknown") { return; } try { await this.prisma.$queryRaw` SELECT version() as version `; this.databaseType = "postgresql"; logger.debug("Detected PostgreSQL database"); return; } catch {} try { await this.prisma.$queryRaw` SELECT VERSION() as version `; this.databaseType = "mysql"; logger.debug("Detected MySQL database"); return; } catch {} this.databaseType = "sqlite"; logger.debug("Detected SQLite database"); } async acquire(timeoutMs = 30000) { await this.detectDatabaseType(); const startTime = Date.now(); logger.debug(`Attempting to acquire migration lock (database: ${this.databaseType})...`); while (Date.now() - startTime < timeoutMs) { const acquired = await this.tryAcquire(); if (acquired) { this.lockAcquired = true; logger.debug("Migration lock acquired successfully"); return; } await new Promise((resolve) => setTimeout(resolve, 1000)); logger.debug("Lock not available, retrying..."); } throw new MigrationLockError(`Failed to acquire migration lock after ${timeoutMs}ms. Another migration may be in progress.`, true); } async tryAcquire() { switch (this.databaseType) { case "postgresql": return await this.acquirePostgresLock(); case "mysql": return await this.acquireMySQLLock(); case "sqlite": return await this.acquireSQLiteLock(); default: logger.warn("Unknown database type, skipping lock acquisition (unsafe!)"); return true; } } async acquirePostgresLock() { try { const result = await this.prisma.$queryRaw` SELECT pg_try_advisory_lock(${this.lockId}) as locked `; return result[0]?.locked ?? false; } catch (error) { logger.error(`Failed to acquire PostgreSQL advisory lock: ${error}`); return false; } } async acquireMySQLLock() { try { const result = await this.prisma.$queryRaw` SELECT GET_LOCK('prisma_migrations_lock', 0) as locked `; return result[0]?.locked === 1; } catch (error) { logger.error(`Failed to acquire MySQL lock: ${error}`); return false; } } async acquireSQLiteLock() { try { await this.prisma.$executeRaw` CREATE TABLE IF NOT EXISTS _prisma_migrations_lock ( id INTEGER PRIMARY KEY CHECK (id = 1), locked_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `; await this.prisma.$executeRaw` INSERT INTO _prisma_migrations_lock (id) VALUES (1) `; return true; } catch (error) { return false; } } async release() { if (!this.lockAcquired) { logger.warn("Attempted to release lock that was not acquired"); return; } logger.debug("Releasing migration lock..."); switch (this.databaseType) { case "postgresql": await this.releasePostgresLock(); break; case "mysql": await this.releaseMySQLLock(); break; case "sqlite": await this.releaseSQLiteLock(); break; default: logger.warn("Unknown database type, skipping lock release"); } this.lockAcquired = false; logger.debug("Migration lock released"); } async releasePostgresLock() { try { await this.prisma.$queryRaw` SELECT pg_advisory_unlock(${this.lockId}) `; } catch (error) { logger.error(`Failed to release PostgreSQL advisory lock: ${error}`); } } async releaseMySQLLock() { try { await this.prisma.$queryRaw` SELECT RELEASE_LOCK('prisma_migrations_lock') `; } catch (error) { logger.error(`Failed to release MySQL lock: ${error}`); } } async releaseSQLiteLock() { try { await this.prisma.$executeRaw` DELETE FROM _prisma_migrations_lock WHERE id = 1 `; } catch (error) { logger.error(`Failed to release SQLite lock: ${error}`); } } async tryLock(fn) { await this.detectDatabaseType(); const acquired = await this.tryAcquire(); if (!acquired) { logger.debug("Lock not available, skipping"); return { acquired: false }; } this.lockAcquired = true; logger.debug("Lock acquired"); try { const result = await fn(); return { acquired: true, result }; } finally { await this.release(); } } async withLock(fn, timeoutMs) { await this.acquire(timeoutMs); try { return await fn(); } finally { await this.release(); } } async isLocked() { await this.detectDatabaseType(); switch (this.databaseType) { case "postgresql": return await this.isPostgresLocked(); case "mysql": return await this.isMySQLLocked(); case "sqlite": return await this.isSQLiteLocked(); default: return false; } } async isPostgresLocked() { try { const result = await this.prisma.$queryRaw` SELECT EXISTS( SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND objid = ${this.lockId} ) as locked `; return result[0]?.locked ?? false; } catch (error) { logger.error(`Failed to check PostgreSQL lock status: ${error}`); return false; } } async isMySQLLocked() { try { const result = await this.prisma.$queryRaw` SELECT IS_USED_LOCK('prisma_migrations_lock') as locked `; const lockStatus = result[0]?.locked; return lockStatus !== null && lockStatus !== 0; } catch (error) { logger.error(`Failed to check MySQL lock status: ${error}`); return false; } } async isSQLiteLocked() { try { const result = await this.prisma.$queryRaw` SELECT COUNT(*) as count FROM _prisma_migrations_lock WHERE id = 1 `; return (result[0]?.count ?? 0) > 0; } catch { return false; } } async forceRelease() { await this.detectDatabaseType(); logger.warn("Force releasing migration lock..."); switch (this.databaseType) { case "postgresql": await this.forceReleasePostgres(); break; case "mysql": await this.forceReleaseMySQL(); break; case "sqlite": await this.forceReleaseSQLite(); break; default: logger.warn("Unknown database type, cannot force release lock"); } logger.info("Migration lock force released"); } async forceReleasePostgres() { try { await this.prisma.$queryRaw` SELECT pg_advisory_unlock_all() `; } catch (error) { logger.error(`Failed to force release PostgreSQL lock: ${error}`); throw error; } } async forceReleaseMySQL() { try { await this.prisma.$queryRaw` SELECT RELEASE_LOCK('prisma_migrations_lock') `; } catch (error) { logger.error(`Failed to force release MySQL lock: ${error}`); throw error; } } async forceReleaseSQLite() { try { await this.prisma.$executeRaw` DELETE FROM _prisma_migrations_lock WHERE id = 1 `; } catch (error) { logger.error(`Failed to force release SQLite lock: ${error}`); throw error; } } } // src/migrations/index.ts function parseSqlMigration(sql) { const upMarker = "-- Migration: Up"; const downMarker = "-- Migration: Down"; const upIndex = sql.indexOf(upMarker); const downIndex = sql.indexOf(downMarker); if (upIndex === -1 || downIndex === -1) { throw new Error(`Invalid migration format. Migration file must contain both "${upMarker}" and "${downMarker}" markers.`); } if (upIndex >= downIndex) { throw new Error(`Invalid migration format. "${upMarker}" must come before "${downMarker}".`); } const upSql = sql.substring(upIndex + upMarker.length, downIndex).trim(); const downSql = sql.substring(downIndex + downMarker.length).trim(); return { up: upSql, down: downSql }; } function splitSqlStatements(sql) { return sql.split(";").map((stmt) => stmt.trim()).filter((stmt) => stmt.length > 0); } async function loadSqlMigration(migrationPath) { const sql = await readFile2(migrationPath, "utf-8"); const parsed = parseSqlMigration(sql); return { up: async (prisma) => { if (!parsed.up) { throw new Error(`No up migration found in ${migrationPath}`); } const statements = splitSqlStatements(parsed.up); for (const statement of statements) { await prisma.$executeRawUnsafe(statement); } }, down: async (prisma) => { if (!parsed.down) { logger.warn(`No down migration available for: ${migrationPath}`); return; } const statements = splitSqlStatements(parsed.down); for (const statement of statements) { await prisma.$executeRawUnsafe(statement); } } }; } class Migrations { prisma; migrationsDir; lock; disableLocking; skipChecksumValidation; lockTimeout; constructor(prisma, options) { this.prisma = prisma; this.migrationsDir = options?.migrationsDir || "./prisma/migrations"; this.disableLocking = options?.disableLocking ?? false; this.skipChecksumValidation = options?.skipChecksumValidation ?? false; this.lockTimeout = options?.lockTimeout ?? 30000; this.lock = this.disableLocking ? null : new MigrationLock(prisma); } async validateMigrationFile(migration) { try { const mod = await loadSqlMigration(migration.path); return typeof mod.up === "function" && typeof mod.down === "function"; } catch { return false; } } async validateAppliedMigrationChecksums() { const shouldSkipValidation = this.skipChecksumValidation; if (shouldSkipValidation) { logger.debug("Skipping checksum validation (disabled)"); return; } logger.debug("Validating checksums for applied migrations..."); const appliedMigrations = await this.prisma.$queryRaw` SELECT id, checksum, migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at ASC `; const allMigrations = await this.getAllMigrations(); await appliedMigrations.reduce(async (prev, applied) => { await prev; const migrationFile = allMigrations.find((m) => m.id === applied.id); if (!migrationFile) { logger.warn(`Applied migration ${applied.id}_${applied.migration_name} not found in migrations directory`); return; } const currentChecksum = await generateChecksum(migrationFile.path); if (currentChecksum !== applied.checksum) { throw createChecksumMismatchError(`${applied.id}_${applied.migration_name}`); } }, Promise.resolve()); logger.debug(`Validated ${appliedMigrations.length} applied migration checksums`); } async dryRun(steps) { const pending = await this.pending(); return steps ? pending.slice(0, steps) : pending; } getMigrationsDir() { logger.debug(`Using migrations dir: ${this.migrationsDir}`); return this.migrationsDir; } async up(steps) { const shouldUseLocking = !this.disableLocking; if (shouldUseLocking) { return await this.lock.withLock(() => this.runUpMigrations(steps), this.lockTimeout); } return await this.runUpMigrations(steps); } async runUpMigrations(steps) { await this.validateAppliedMigrationChecksums(); const applied = await this.getApplied(); logger.debug(`Found ${applied.length} applied migrations`); const all = await this.getAllMigrations(); logger.debug(`Found ${all.length} total migrations`); const pending = all.filter((m) => !applied.includes(m.id)); logger.debug(`Found ${pending.length} pending migrations`); let toRun = pending; if (steps) toRun = pending.slice(0, steps); logger.debug(`Will run ${toRun.length} migrations`); await toRun.reduce(async (prev, migration) => { await prev; await this.runMigrationInTransaction(migration, "up"); }, Promise.resolve()); return toRun.length; } async runMigrationInTransaction(migration, direction) { const migrationName = `${migration.id}_${migration.name}`; const isUpMigration = direction === "up"; logger.info(`Running ${migrationName}...`); try { const isValid = await this.validateMigrationFile(migration); const isInvalidMigration = !isValid; if (isInvalidMigration) { throw createInvalidMigrationError(migrationName, "missing up or down function"); } const mod = await loadSqlMigration(migration.path); const migrationFn = isUpMigration ? mod.up : mod.down; const isMissingMigrationFunction = typeof migrationFn !== "function"; if (isMissingMigrationFunction) { throw new Error(`Migration ${migrationName} does not export a '${direction}' function`); } await this.prisma.$executeRaw`BEGIN`; try { await migrationFn(this.prisma); if (isUpMigration) { const checksum = await generateChecksum(migration.path); await this.prisma.$executeRaw`INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, logs, started_at, applied_steps_count) VALUES (${migration.id}, ${checksum}, NOW(), ${migration.name}, NULL, NOW(), 1)`; } else { await this.prisma.$executeRaw`DELETE FROM _prisma_migrations WHERE id = ${migration.id}`; } await this.prisma.$executeRaw`COMMIT`; const action = isUpMigration ? "Applied" : "Rolled back"; logger.info(`✓ ${action} ${migrationName}`); } catch (error) { await this.prisma.$executeRaw`ROLLBACK`; const migrationError = error instanceof Error ? error : new Error(String(error)); throw createTransactionFailedError(migrationName, migrationError); } } catch (error) { const action = isUpMigration ? "apply" : "rollback"; logger.error(`Failed to ${action} migration ${migrationName}`); const errorMessage = error instanceof Error ? error.message : String(error); logger.error(errorMessage); throw error; } } async down(steps = 1) { const shouldUseLocking = !this.disableLocking; if (shouldUseLocking) { return await this.lock.withLock(() => this.runDownMigrations(steps), this.lockTimeout); } return await this.runDownMigrations(steps); } async runDownMigrations(steps) { const applied = await this.getApplied(); logger.debug(`Found ${applied.length} applied migrations`); const toRollback = applied.slice(-steps); logger.debug(`Will rollback ${toRollback.length} migrations`); const reversed = toRollback.toReversed(); await reversed.reduce(async (prev, id) => { await prev; const migration = await this.findMigration(id); const migrationNotFound = !migration; if (migrationNotFound) { throw createMigrationNotFoundError(id); } await this.runMigrationInTransaction(migration, "down"); }, Promise.resolve()); return toRollback.length; } async status() { const applied = await this.getApplied(); const all = await this.getAllMigrations(); logger.info(` Migration Status: `); for (const migration of all) { const status = applied.includes(migration.id) ? "[x]" : "[ ]"; logger.info(`${status} ${migration.id}_${migration.name}`); } } async pending() { const applied = await this.getApplied(); const all = await this.getAllMigrations(); return all.filter((m) => !applied.includes(m.id)); } async applied() { const appliedIds = await this.getApplied(); const all = await this.getAllMigrations(); return all.filter((m) => appliedIds.includes(m.id)); } async latest() { const applied = await this.applied(); return applied.length > 0 ? applied[applied.length - 1] : null; } async reset() { const shouldUseLocking = !this.disableLocking; if (shouldUseLocking) { return await this.lock.withLock(() => this.runResetMigrations(), this.lockTimeout); } return await this.runResetMigrations(); } async runResetMigrations() { const applied = await this.applied(); logger.debug(`Found ${applied.length} applied migrations to reset`); const count = applied.length; const reversed = applied.toReversed(); await reversed.reduce(async (prev, migration) => { await prev; await this.runMigrationInTransaction(migration, "down"); }, Promise.resolve()); return count; } async fresh() { await this.reset(); return await this.up(); } async refresh() { const down = await this.reset(); const up = await this.up(); return { down, up }; } async upTo(migrationId) { const shouldUseLocking = !this.disableLocking; if (shouldUseLocking) { return await this.lock.withLock(() => this.runUpToMigration(migrationId), this.lockTimeout); } return await this.runUpToMigration(migrationId); } async runUpToMigration(migrationId) { await this.validateAppliedMigrationChecksums(); const applied = await this.getApplied(); logger.debug(`Found ${applied.length} applied migrations`); const all = await this.getAllMigrations(); logger.debug(`Found ${all.length} total migrations`); const pending = all.filter((m) => !applied.includes(m.id)); logger.debug(`Found ${pending.length} pending migrations`); const targetIndex = pending.findIndex((m) => m.id === migrationId); const migrationNotFound = targetIndex === -1; if (migrationNotFound) { throw new Error(`Migration ${migrationId} not found in pending migrations`); } const toRun = pending.slice(0, targetIndex + 1); logger.debug(`Will run ${toRun.length} migrations up to ${migrationId}`); await toRun.reduce(async (prev, migration) => { await prev; await this.runMigrationInTransaction(migration, "up"); }, Promise.resolve()); return toRun.length; } async downTo(migrationId) { const shouldUseLocking = !this.disableLocking; if (shouldUseLocking) { return await this.lock.withLock(() => this.runDownToMigration(migrationId), this.lockTimeout); } return await this.runDownToMigration(migrationId); } async runDownToMigration(migrationId) { const appliedIds = await this.getApplied(); logger.debug(`Found ${appliedIds.length} applied migrations`); const targetIndex = appliedIds.indexOf(migrationId); const migrationNotFound = targetIndex === -1; if (migrationNotFound) { throw new Error(`Migration ${migrationId} not found in applied migrations`); } const toRollback = appliedIds.slice(targetIndex + 1); logger.debug(`Will rollback ${toRollback.length} migrations down to ${migrationId}`); const reversed = toRollback.toReversed(); await reversed.reduce(async (prev, id) => { await prev; const migration = await this.findMigration(id); const migrationNotFound2 = !migration; if (migrationNotFound2) { throw createMigrationNotFoundError(id); } await this.runMigrationInTransaction(migration, "down"); }, Promise.resolve()); return toRollback.length; } async upIfNotLocked(steps) { const lockingDisabled = this.disableLocking; if (lockingDisabled) { const count = await this.runUpMigrations(steps); return { ran: true, count }; } const result = await this.lock.tryLock(() => this.runUpMigrations(steps)); if (!result.acquired) { logger.info("Another instance is running migrations, skipping"); return { ran: false, count: 0, reason: "Another instance is running migrations" }; } return { ran: true, count: result.result ?? 0 }; } async checkLockStatus() { const hasLock = !this.lock; if (hasLock) { logger.warn("Locking is disabled"); return false; } return await this.lock.isLocked(); } async releaseLock() { const hasNoLock = !this.lock; if (hasNoLock) { logger.warn("Locking is disabled, no lock to release"); return; } await this.lock.forceRelease(); } async getApplied() { const result = await this.prisma.$queryRaw` SELECT id FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at ASC `; return result.map((r) => r.id); } async detectMigrationFile(migrationsDir, dirName) { const sqlPath = join(migrationsDir, dirName, "migration.sql"); const hasSqlFile = await access(sqlPath).then(() => true).catch(() => false); if (!hasSqlFile) { throw new Error(`No migration.sql file found in ${dirName}`); } return sqlPath; } isValidMigrationDir(name) { return /^(\d+)_(.+)$/.test(name); } parseMigrationName(name) { const match = name.match(/^(\d+)_(.+)$/); if (!match) throw new Error(`Invalid migration name: ${name}`); const [, id, migrationName] = match; return { id, name: migrationName }; } filterValidDirectories(entries) { return entries.filter((entry) => { const isDirectory = entry.isDirectory(); const isValid = isDirectory && this.isValidMigrationDir(entry.name); logger.debug(`Entry ${entry.name}: directory=${isDirectory}, valid=${isValid}`); return isValid; }); } async mapToMigrationFiles(migrationsDir, validDirs) { return await Promise.all(validDirs.map(async (entry) => { const { id, name } = this.parseMigrationName(entry.name); const path = await this.detectMigrationFile(migrationsDir, entry.name); logger.debug(`Mapped migration: ${id}_${name} at ${path}`); return { id, name, path }; })); } async getAllMigrations() { const migrationsDir = this.getMigrationsDir(); logger.debug(`Reading migrations from: ${migrationsDir}`); const entries = await readdir(migrationsDir, { withFileTypes: true }); logger.debug(`Found ${entries.length} entries in migrations directory`); const validDirs = this.filterValidDirectories(entries); const migrations = await this.mapToMigrationFiles(migrationsDir, validDirs); const sorted = migrations.sort((a, b) => a.id.localeCompare(b.id)); logger.debug(`Loaded ${sorted.length} valid migrations`); return sorted; } async findMigration(id) { const all = await this.getAllMigrations(); return all.find((m) => m.id === id) || null; } } export { Migrations }; //# debugId=33696B5256DFB18264756E2164756E21