UNPKG

prisma-migrations

Version:

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

1,547 lines (1,520 loc) 85.1 kB
#!/usr/bin/env node import { createRequire } from "node:module"; var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __require = /* @__PURE__ */ createRequire(import.meta.url); // 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); function setLogLevel(level) { logLevel = level; logger.level = level; } // 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/spinner.ts var frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; class Spinner { _text; frameIndex = 0; intervalId = null; isSpinning = false; constructor(text) { this._text = text; } start() { if (this.isSpinning) return this; this.isSpinning = true; this.render(); this.intervalId = setInterval(() => { this.frameIndex = (this.frameIndex + 1) % frames.length; this.render(); }, 80); return this; } render() { if (!this.isSpinning) return; const frame = frames[this.frameIndex]; process.stdout.write(`\r${colors.cyan(frame)} ${this._text}`); } clear() { process.stdout.write("\r" + " ".repeat(this._text.length + 10) + "\r"); } stop() { if (!this.isSpinning) return; this.isSpinning = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.clear(); } succeed(text) { this.stop(); console.log(colors.green("✔") + " " + (text || this._text)); } fail(text) { this.stop(); console.log(colors.red("✖") + " " + (text || this._text)); } set text(value) { this._text = value; } } function spinner(text) { return new Spinner(text); } // src/utils/table.ts function createTable(headers, rows) { const colWidths = [10, 50]; const lines = []; const topBorder = "┌" + colWidths.map((w) => "─".repeat(w)).join("┬") + "┐"; const midBorder = "├" + colWidths.map((w) => "─".repeat(w)).join("┼") + "┤"; const bottomBorder = "└" + colWidths.map((w) => "─".repeat(w)).join("┴") + "┘"; const formatRow = (cells) => { return "│" + cells.map((cell, i) => { const stripped = stripAnsi(cell); const visibleLength = stripped.length; const padding = colWidths[i] - visibleLength; return cell + " ".repeat(Math.max(0, padding)); }).join("│") + "│"; }; lines.push(topBorder); lines.push(formatRow(headers)); lines.push(midBorder); rows.forEach((row) => { lines.push(formatRow(row)); }); lines.push(bottomBorder); return lines.join(` `); } function stripAnsi(str) { const escapeCode = "\x1B"; const ansiPattern = new RegExp(`${escapeCode}\\[\\d+m`, "g"); return str.replace(ansiPattern, ""); } // src/utils/index.ts function generateMigrationId() { const now = new Date; const timestamp = now.toISOString().replace(/[-:]/g, "").replace("T", ""); return timestamp.substring(0, 14) + timestamp.substring(15, 18); } function validateMigrationName(name) { return /^[a-z0-9_]+$/.test(name); } 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; } } // src/utils/prompts.ts import * as readline from "readline"; class Prompt { rl; constructor() { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout }); } close() { this.rl.close(); } ensureCookedMode() { if (process.stdin.isTTY) { process.stdin.setRawMode(false); } } async input(message, defaultValue, validate) { return new Promise((resolve) => { const prompt = defaultValue ? `${message} (${defaultValue}): ` : `${message}: `; const askForInput = () => { this.ensureCookedMode(); this.rl.question(prompt, (answer) => { const value = answer.trim() || defaultValue || ""; if (validate) { const result = validate(value); if (result !== true) { console.log(`⚠️ ${result}`); askForInput(); return; } } resolve(value); }); }; askForInput(); }); } async number(message, defaultValue, validate) { return new Promise((resolve) => { const prompt = defaultValue ? `${message} (${defaultValue}): ` : `${message}: `; const askForNumber = () => { this.ensureCookedMode(); this.rl.question(prompt, (answer) => { const trimmed = answer.trim(); const num = trimmed === "" ? defaultValue : parseInt(trimmed, 10); if (num === undefined) { console.log("⚠️ Please enter a number"); askForNumber(); return; } if (isNaN(num)) { console.log("⚠️ Please enter a valid number"); askForNumber(); return; } if (validate) { const result = validate(num); if (result !== true) { console.log(`⚠️ ${result}`); askForNumber(); return; } } resolve(num); }); }; askForNumber(); }); } async confirm(message, defaultValue = true) { return new Promise((resolve) => { const defaultText = defaultValue ? "Y/n" : "y/N"; this.ensureCookedMode(); this.rl.question(`${message} (${defaultText}): `, (answer) => { const normalized = answer.trim().toLowerCase(); if (normalized === "") { resolve(defaultValue); } else { resolve(normalized === "y" || normalized === "yes"); } }); }); } async list(message, choices) { console.log(` ${message}`); choices.forEach((choice, index) => { console.log(` ${index + 1}. ${choice.name}`); }); return new Promise((resolve) => { const askForChoice = () => { this.ensureCookedMode(); this.rl.question(` Enter your choice (number): `, (answer) => { const num = parseInt(answer.trim(), 10); if (isNaN(num) || num < 1 || num > choices.length) { console.log(`⚠️ Invalid choice. Please enter a number between 1 and ${choices.length}`); askForChoice(); } else { resolve(choices[num - 1].value); } }); }; askForChoice(); }); } } // src/cli/commands/up/index.ts function createDefaultDependencies() { return { getSteps: async (maxSteps) => { const prompt = new Prompt; const steps = await prompt.number(`How many migrations? (1-${maxSteps})`, 1, (input) => { if (input === undefined) return "Please enter a number"; const isValid = input >= 1 && input <= maxSteps; if (!isValid) { return `Please enter a number between 1 and ${maxSteps}`; } return true; }); prompt.close(); return steps; }, getMigrationId: async (choices) => { const prompt = new Prompt; const migrationId = await prompt.list("Run migrations up to (inclusive):", choices); prompt.close(); return migrationId; }, getMode: async () => { const choices = [ { name: colors.cyan("All pending migrations"), value: "all" }, { name: colors.yellow("Select number of migrations"), value: "steps" }, { name: colors.blue("Select specific migration to run up to"), value: "specific" } ]; const prompt = new Prompt; const mode = await prompt.list("How many migrations do you want to run?", choices); prompt.close(); return mode; } }; } async function up(prisma, steps, config, interactive) { const migrations = new Migrations(prisma, config); if (interactive) { return await interactiveUp(migrations); } const spin = spinner("Loading migrations...").start(); try { spin.text = "Running migrations..."; const count = await migrations.up(steps); spin.succeed(`Applied ${count} migration(s)`); const hasAppliedMigrations = count > 0; if (hasAppliedMigrations) { showSuccessTable(count); } return count; } catch (error) { spin.fail("Migration failed"); logger.error(error); throw error; } } async function interactiveUp(migrations, deps = createDefaultDependencies()) { const pending = await migrations.pending(); const hasPending = pending.length > 0; if (!hasPending) { console.log(colors.green("No pending migrations")); return 0; } const mode = await deps.getMode(); const count = await runMigrationsForMode(mode, migrations, pending, deps); const hasAppliedMigrations = count > 0; if (hasAppliedMigrations) { showSuccessTable(count); } return count; } async function runMigrationsForMode(mode, migrations, pending, deps = createDefaultDependencies()) { const isAllMode = mode === "all"; if (isAllMode) { return await runAllMigrations(migrations); } const isStepsMode = mode === "steps"; if (isStepsMode) { return await runStepsMigrations(migrations, pending, deps); } const isSpecificMode = mode === "specific"; if (isSpecificMode) { return await runToSpecificMigration(migrations, pending, deps); } return 0; } async function runAllMigrations(migrations) { const spin = spinner("Running migrations...").start(); try { const count = await migrations.up(); spin.succeed(`Applied ${count} migration(s)`); return count; } catch (error) { spin.fail("Migration failed"); logger.error(error); throw error; } } async function runStepsMigrations(migrations, pending, deps = createDefaultDependencies()) { const steps = await deps.getSteps(pending.length); const spin = spinner("Running migrations...").start(); try { const count = await migrations.up(steps); spin.succeed(`Applied ${count} migration(s)`); return count; } catch (error) { spin.fail("Migration failed"); logger.error(error); throw error; } } async function runToSpecificMigration(migrations, pending, deps = createDefaultDependencies()) { const migrationChoices = pending.map((m) => ({ name: `${m.id}_${m.name}`, value: m.id })); const migrationId = await deps.getMigrationId(migrationChoices); const spin = spinner("Running migrations...").start(); try { const count = await migrations.upTo(migrationId); spin.succeed(`Applied ${count} migration(s)`); return count; } catch (error) { spin.fail("Migration failed"); logger.error(error); throw error; } } function showSuccessTable(count) { const table = createTable([colors.cyan("Status"), colors.cyan("Migrations")], [[colors.green("[x]"), `${count} migration(s) applied successfully`]]); console.log(table); } // src/cli/commands/down/index.ts function createDefaultDependencies2() { return { getSteps: async (maxSteps) => { const prompt = new Prompt; const steps = await prompt.number(`How many migrations? (1-${maxSteps})`, 1, (input) => { if (input === undefined) return "Please enter a number"; const isValid = input >= 1 && input <= maxSteps; if (!isValid) { return `Please enter a number between 1 and ${maxSteps}`; } return true; }); prompt.close(); return steps; }, getMigrationId: async (choices) => { const prompt = new Prompt; const migrationId = await prompt.list("Rollback down to (this migration will remain applied):", choices); prompt.close(); return migrationId; }, getMode: async () => { const choices = [ { name: colors.cyan("Last migration only"), value: "one" }, { name: colors.yellow("Select number of migrations"), value: "steps" }, { name: colors.blue("Select specific migration to rollback to"), value: "specific" }, { name: colors.red("All migrations (reset)"), value: "all" } ]; const prompt = new Prompt; const mode = await prompt.list("How many migrations do you want to rollback?", choices); prompt.close(); return mode; }, confirmReset: async () => { const prompt = new Prompt; const confirm = await prompt.confirm(colors.red("Are you sure you want to rollback ALL migrations?"), false); prompt.close(); return confirm === true; } }; } async function down(prisma, steps = 1, config, interactive) { const migrations = new Migrations(prisma, config); if (interactive) { return await interactiveDown(migrations); } const spin = spinner("Rolling back migrations...").start(); try { const count = await migrations.down(steps); spin.succeed(`Rolled back ${count} migration(s)`); const hasRolledBack = count > 0; if (hasRolledBack) { showRollbackTable(count); } return count; } catch (error) { spin.fail("Rollback failed"); logger.error(error); throw error; } } async function interactiveDown(migrations, deps = createDefaultDependencies2()) { const applied = await migrations.applied(); const hasApplied = applied.length > 0; if (!hasApplied) { console.log(colors.yellow("No applied migrations to rollback")); return 0; } const mode = await deps.getMode(); const count = await runRollbackForMode(mode, migrations, applied, deps); const hasRolledBack = count > 0; if (hasRolledBack) { showRollbackTable(count); } return count; } async function runRollbackForMode(mode, migrations, applied, deps = createDefaultDependencies2()) { const isOneMode = mode === "one"; if (isOneMode) { return await rollbackOne(migrations); } const isAllMode = mode === "all"; if (isAllMode) { return await rollbackAll(migrations, deps); } const isStepsMode = mode === "steps"; if (isStepsMode) { return await rollbackSteps(migrations, applied, deps); } const isSpecificMode = mode === "specific"; if (isSpecificMode) { return await rollbackToSpecific(migrations, applied, deps); } return 0; } async function rollbackOne(migrations) { const spin = spinner("Rolling back migration...").start(); try { const count = await migrations.down(1); spin.succeed(`Rolled back ${count} migration(s)`); return count; } catch (error) { spin.fail("Rollback failed"); logger.error(error); throw error; } } async function rollbackAll(migrations, deps = createDefaultDependencies2()) { const isConfirmed = await deps.confirmReset(); if (!isConfirmed) { console.log(colors.yellow("Cancelled")); return 0; } const spin = spinner("Rolling back all migrations...").start(); try { const count = await migrations.reset(); spin.succeed(`Rolled back ${count} migration(s)`); return count; } catch (error) { spin.fail("Rollback failed"); logger.error(error); throw error; } } async function rollbackSteps(migrations, applied, deps = createDefaultDependencies2()) { const steps = await deps.getSteps(applied.length); const spin = spinner("Rolling back migrations...").start(); try { const count = await migrations.down(steps); spin.succeed(`Rolled back ${count} migration(s)`); return count; } catch (error) { spin.fail("Rollback failed"); logger.error(error); throw error; } } async function rollbackToSpecific(migrations, applied, deps = createDefaultDependencies2()) { const migrationChoices = applied.map((m) => ({ name: `${m.id}_${m.name}`, value: m.id })); const migrationId = await deps.getMigrationId(migrationChoices); const spin = spinner("Rolling back migrations...").start(); try { const count = await migrations.downTo(migrationId); spin.succeed(`Rolled back ${count} migration(s)`); return count; } catch (error) { spin.fail("Rollback failed"); logger.error(error); throw error; } } function showRollbackTable(count) { const table = createTable([colors.cyan("Status"), colors.cyan("Migrations")], [[colors.yellow("[ ]"), `${count} migration(s) rolled back`]]); console.log(table); } // src/cli/commands/init/index.ts import { writeFile, mkdir } from "fs/promises"; import { join as join2 } from "path"; async function init() { const migrationsDir = join2(process.cwd(), "prisma", "migrations"); await mkdir(migrationsDir, { recursive: true }); const timestamp = generateMigrationId(); const migrationName = "initial_migration"; const migrationDir = join2(migrationsDir, `${timestamp}_${migrationName}`); await mkdir(migrationDir, { recursive: true }); const migrationContent = `-- Migration: Up -- Add your forward migration SQL here -- This will be executed when running: prisma-migrations up -- Example: -- CREATE TABLE users ( -- id SERIAL PRIMARY KEY, -- email VARCHAR(255) UNIQUE NOT NULL, -- created_at TIMESTAMP DEFAULT NOW() -- ); -- Migration: Down -- Add your rollback migration SQL here -- This will be executed when running: prisma-migrations down -- Example: -- DROP TABLE IF EXISTS users; `; await writeFile(join2(migrationDir, "migration.sql"), migrationContent); console.log(colors.green(` ✓ Created migration: ${timestamp}_${migrationName}`)); console.log(colors.gray(` Location: ${migrationDir}`)); console.log(colors.gray(` File: migration.sql`)); } // src/cli/commands/create/index.ts import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises"; import { join as join3 } from "path"; async function create(name) { let migrationName = name; if (!migrationName) { const prompt = new Prompt; migrationName = await prompt.input("Migration name:", undefined, (input) => { if (input.length === 0) return "Name is required"; if (!validateMigrationName(input)) { return "Name must contain only lowercase letters, numbers, and underscores"; } return true; }); prompt.close(); } else if (!validateMigrationName(migrationName)) { throw new Error("Migration name must contain only lowercase letters, numbers, and underscores"); } const spin = spinner("Creating migration...").start(); try { const migrationsDir = join3(process.cwd(), "prisma", "migrations"); await mkdir2(migrationsDir, { recursive: true }); const timestamp = generateMigrationId(); const migrationDir = join3(migrationsDir, `${timestamp}_${migrationName}`); await mkdir2(migrationDir, { recursive: true }); const migrationContent = `-- Migration: Up -- Add your forward migration SQL here -- This will be executed when running: prisma-migrations up -- Migration: Down -- Add your rollback migration SQL here -- This will be executed when running: prisma-migrations down `; await writeFile2(join3(migrationDir, "migration.sql"), migrationContent); spin.succeed("Migration created"); console.log(colors.cyan(` ${timestamp}_${migrationName}`)); console.log(colors.gray(`Location: ${migrationDir}`)); } catch (error) { spin.fail("Failed to create migration"); throw error; } } // src/cli/commands/prisma/index.ts import { spawn } from "child_process"; async function execPrismaCommand(command, args = []) { return new Promise((resolve, reject) => { console.log(colors.cyan(` Running: prisma ${command} ${args.join(" ")} `)); const prisma = spawn("npx", ["prisma", command, ...args], { stdio: "inherit" }); prisma.on("close", (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Prisma command exited with code ${code}`)); } }); prisma.on("error", (error) => { reject(error); }); }); } async function dev(name) { const args = ["dev"]; if (name) { args.push("--name", name); } await execPrismaCommand("migrate", args); } async function deploy() { await execPrismaCommand("migrate", ["deploy"]); } async function resolve(options) { const args = ["resolve"]; if (options.applied) { args.push("--applied", options.applied); } if (options.rolledBack) { args.push("--rolled-back", options.rolledBack); } await execPrismaCommand("migrate", args); } async function dbPush(options = {}) { const args = ["push"]; if (options.skipGenerate) { args.push("--skip-generate"); } await execPrismaCommand("db", args); } async function generate() { await execPrismaCommand("generate", []); } // src/cli/commands/setup/source.ts import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; import { join as join4 } from "path"; function readPackageJson(cwd) { const pkgPath = join4(cwd, "package.json"); if (!existsSync(pkgPath)) { throw new Error("package.json not found. Run this command from a package directory."); } return JSON.parse(readFileSync(pkgPath, "utf-8")); } function writePackageJson(cwd, pkg) { const pkgPath = join4(cwd, "package.json"); writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + ` `); } function updatePrismaSchema(schemaPath) { if (!existsSync(schemaPath)) { throw new Error(`Prisma schema not found at ${schemaPath}`); } let schema = readFileSync(schemaPath, "utf-8"); const hasGenerator = /generator\s+client\s*\{/.test(schema); if (hasGenerator) { const hasOutput = /output\s*=/.test(schema); if (!hasOutput) { schema = schema.replace(/(generator\s+client\s*\{[^}]*)/, `$1 output = "../src/generated/client"`); console.log(colors.cyan(" Updated existing Prisma generator with custom output")); } else { console.log(colors.yellow(" Prisma generator already has custom output - skipping")); } } else { const generatorBlock = ` generator client { provider = "prisma-client-js" output = "../src/generated/client" } `; schema = schema.replace(/(datasource\s+\w+\s*\{[^}]*\})/, `$1 ${generatorBlock}`); console.log(colors.cyan(" Added Prisma generator with custom output")); } writeFileSync(schemaPath, schema); } function createDbFiles(srcDir, packageName) { const dbDir = join4(srcDir, "db"); if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); } const typesPath = join4(dbDir, "types.ts"); const typesContent = `// Auto-generated by prisma-migrations // Type-only exports - no runtime code // Other packages can import: import type * as Prisma from "${packageName}/db/types" export type * from '../generated/client'; `; writeFileSync(typesPath, typesContent); console.log(colors.cyan(" Created sr