prisma-migrations
Version:
A Node.js library to manage Prisma ORM migrations like other ORMs
1,547 lines (1,520 loc) • 85.1 kB
JavaScript
#!/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