UNPKG

@mikro-orm/core

Version:

TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.

565 lines (564 loc) 22 kB
import { Utils } from './Utils.js'; export class AbstractMigrator { em; runner; storage; generator; driver; config; options; absolutePath; initialized = false; #listeners = new Map(); constructor(em) { this.em = em; this.driver = this.em.getDriver(); this.config = this.em.config; this.options = this.config.get('migrations'); this.initServices(); this.registerDefaultListeners(); } /** * @inheritDoc */ on(eventName, listener) { if (!this.#listeners.has(eventName)) { this.#listeners.set(eventName, new Set()); } this.#listeners.get(eventName).add(listener); return this; } /** * @inheritDoc */ off(eventName, listener) { this.#listeners.get(eventName)?.delete(listener); return this; } /** * @inheritDoc */ async getExecuted(options) { await this.init(); const schema = options?.schema ?? this.options.schema; this.storage.setRunSchema?.(schema); try { return await this.storage.getExecutedMigrations(); } finally { this.storage.unsetRunSchema?.(); } } /** * @inheritDoc */ async getPending(options) { await this.init(); const schema = options?.schema ?? this.options.schema; this.storage.setRunSchema?.(schema); try { const all = await this.discoverMigrations(); const executed = new Set(await this.storage.executed()); return all.filter(m => !executed.has(m.name)).map(m => ({ name: m.name, path: m.path })); } finally { this.storage.unsetRunSchema?.(); } } /** * @inheritDoc */ async up(options) { return this.runMigrations('up', options); } /** * @inheritDoc */ async down(options) { return this.runMigrations('down', options); } /** * @inheritDoc */ async rollup(migrations) { await this.init(); const { fs } = await import('@mikro-orm/core/fs-utils'); const all = await this.discoverMigrations(); const executedSet = new Set(await this.storage.executed()); let toRollup; if (migrations && migrations.length > 0) { const requested = new Set(migrations.map(m => this.getMigrationFilename(m))); toRollup = all.filter(m => requested.has(m.name)); const found = new Set(toRollup.map(m => m.name)); const notFound = [...requested].filter(name => !found.has(name)); if (notFound.length > 0) { throw new Error(`Migrations not found: ${notFound.join(', ')}`); } const notExecuted = toRollup.filter(m => !executedSet.has(m.name)); if (notExecuted.length > 0) { throw new Error(`Cannot roll up migrations that have not been executed: ${notExecuted.map(m => m.name).join(', ')}`); } } else { toRollup = all.filter(m => executedSet.has(m.name)); } if (toRollup.length < 2) { throw new Error('At least 2 executed migrations are required for rollup'); } const withoutPath = toRollup.filter(m => !m.path); if (withoutPath.length > 0) { throw new Error(`Cannot roll up migrations without file paths (class-based migrations): ${withoutPath.map(m => m.name).join(', ')}`); } const upBodies = []; const downBodies = []; const placeholder = `__mikro_orm_rollup_${Date.now()}__`; for (const migration of toRollup) { const source = await fs.readFile(migration.path); const upBody = this.extractMethodBody(source, 'up'); const downBody = this.extractMethodBody(source, 'down'); if (upBody) { upBodies.push(` // --- merged from ${migration.name} ---\n${upBody}`); } if (downBody) { downBodies.unshift(` // --- merged from ${migration.name} ---\n${downBody}`); } } const diff = { up: [placeholder], down: downBodies.length > 0 ? [placeholder] : [], }; const [templateCode, fileName] = await this.generator.generate(diff); const placeholderRe = new RegExp(`^.*${placeholder}.*$`, 'm'); let code = templateCode.replace(placeholderRe, upBodies.join('\n')); if (downBodies.length > 0) { code = code.replace(placeholderRe, downBodies.join('\n')); } await fs.writeFile(fs.normalizePath(this.absolutePath, fileName), code, { flush: true }); const updateStorage = async () => { for (const migration of toRollup) { await this.storage.unlogMigration({ name: migration.name }); } await this.storage.logMigration({ name: fileName.replace(/\.[jt]s$/, '') }); }; if (this.options.transactional) { await this.driver.getConnection().transactional(async (trx) => { this.storage.setMasterMigration(trx); try { await updateStorage(); } finally { this.storage.unsetMasterMigration(); } }); } else { await updateStorage(); } await Promise.all(toRollup.map(migration => fs.unlink(migration.path))); return { fileName, code, diff: { up: [], down: [] } }; } /** * Extracts the body of a method from migration source code using brace counting. * Returns the raw lines between the opening and closing braces, or empty string if not found. * @internal */ extractMethodBody(source, methodName) { const lines = source.split('\n'); // match method declarations, not occurrences in comments/strings — require preceding whitespace or keyword const methodPattern = new RegExp(`^\\s+(?:override\\s+|async\\s+)*${methodName}\\s*\\(`); let methodLine = -1; for (let i = 0; i < lines.length; i++) { if (methodPattern.test(lines[i])) { methodLine = i; break; } } if (methodLine === -1) { return ''; } let braceCount = 0; let bodyStart = -1; let bodyEnd = -1; let bodyStartCol = -1; let bodyEndCol = -1; let inBacktick = false; let inBlockComment = false; // stack tracks brace depth at which each template expression `${...}` was entered const templateExprStack = []; for (let i = methodLine; i < lines.length; i++) { const line = lines[i]; for (let j = 0; j < line.length; j++) { // handle multi-line block comments if (inBlockComment) { if (line[j] === '*' && j + 1 < line.length && line[j + 1] === '/') { inBlockComment = false; j++; } continue; } // handle multi-line template literals if (inBacktick) { if (line[j] === '\\') { j++; } else if (line[j] === '`') { inBacktick = false; } else if (line[j] === '$' && j + 1 < line.length && line[j + 1] === '{') { // entering template expression — resume brace counting templateExprStack.push(braceCount); inBacktick = false; j++; // skip the { braceCount++; } continue; } const ch = line[j]; // single/double quoted strings (single-line only) if (ch === "'" || ch === '"') { const quote = ch; j++; while (j < line.length) { if (line[j] === '\\') { j++; } else if (line[j] === quote) { break; } j++; } continue; } // template literal start if (ch === '`') { inBacktick = true; continue; } // single-line comment if (ch === '/' && j + 1 < line.length && line[j + 1] === '/') { break; } // block comment start if (ch === '/' && j + 1 < line.length && line[j + 1] === '*') { inBlockComment = true; j++; continue; } if (ch === '{') { if (braceCount === 0) { bodyStart = i; bodyStartCol = j + 1; } braceCount++; } else if (ch === '}') { braceCount--; // closing a template expression — re-enter backtick mode if (templateExprStack.length > 0 && braceCount === templateExprStack[templateExprStack.length - 1]) { templateExprStack.pop(); inBacktick = true; continue; } if (braceCount === 0) { bodyEnd = i; bodyEndCol = j; break; } } } if (bodyEnd !== -1) { break; } } if (bodyStart === -1 || bodyEnd === -1) { return ''; } // single-line method body: extract content between braces on the same line if (bodyStart === bodyEnd) { const content = lines[bodyStart].slice(bodyStartCol, bodyEndCol).trim(); return content ? ` ${content}` : ''; } return lines.slice(bodyStart + 1, bodyEnd).join('\n'); } /** * @inheritDoc */ async logMigration(name) { await this.init(); await this.storage.ensureTable?.(); await this.storage.logMigration({ name }); } /** * @inheritDoc */ async unlogMigration(name) { await this.init(); await this.storage.ensureTable?.(); await this.storage.unlogMigration({ name }); } async init() { if (this.initialized) { return; } this.initialized = true; await this.initPaths(); } async initPaths() { if (this.absolutePath || this.options.migrationsList) { return; } const { fs } = await import('@mikro-orm/core/fs-utils'); this.detectSourceFolder(fs); /* v8 ignore next */ const key = this.config.get('preferTs', Utils.detectTypeScriptSupport()) && this.options.pathTs ? 'pathTs' : 'path'; this.absolutePath = fs.absolutePath(this.options[key], this.config.get('baseDir')); try { fs.ensureDir(this.absolutePath); } catch { // read-only filesystem — read-only operations (e.g. `getPending` with a // snapshot) may still succeed; write operations will fail on their own } } initServices() { this.runner = this.createRunner(); this.storage = this.createStorage(); if (this.options.generator) { this.generator = new this.options.generator(this.driver, this.config.getNamingStrategy(), this.options); } else { this.generator = this.getDefaultGenerator(); } } resolve(params) { const createMigrationHandler = async (method, afterRun) => { const { fs } = await import('@mikro-orm/core/fs-utils'); const migration = await fs.dynamicImport(params.path); const MigrationClass = Object.values(migration).find(cls => typeof cls === 'function' && typeof cls.constructor === 'function'); const instance = new MigrationClass(this.driver, this.config); await this.runner.run(instance, method, afterRun); }; return { name: this.storage.getMigrationName(params.name), path: params.path, up: afterRun => createMigrationHandler('up', afterRun), down: afterRun => createMigrationHandler('down', afterRun), }; } initialize(MigrationClass, name) { const instance = new MigrationClass(this.driver, this.config); return { name: this.storage.getMigrationName(name), up: afterRun => this.runner.run(instance, 'up', afterRun), down: afterRun => this.runner.run(instance, 'down', afterRun), }; } /** * Checks if `src` folder exists, it so, tries to adjust the migrations and seeders paths automatically to use it. * If there is a `dist` or `build` folder, it will be used for the JS variant (`path` option), while the `src` folder will be * used for the TS variant (`pathTs` option). * * If the default folder exists (e.g. `/migrations`), the config will respect that, so this auto-detection should not * break existing projects, only help with the new ones. */ detectSourceFolder(fs) { const baseDir = this.config.get('baseDir'); const defaultPath = './migrations'; if (!fs.pathExists(baseDir + '/src')) { this.options.path ??= defaultPath; return; } const exists = fs.pathExists(`${baseDir}/${defaultPath}`); const distDir = fs.pathExists(baseDir + '/dist'); const buildDir = fs.pathExists(baseDir + '/build'); // if neither `dist` nor `build` exist, we use the `src` folder as it might be a JS project without building, but with `src` folder /* v8 ignore next */ const path = distDir ? './dist' : buildDir ? './build' : './src'; // only if the user did not provide any values and if the default path does not exist if (!this.options.path && !this.options.pathTs && !exists) { this.options.path = `${path}/migrations`; this.options.pathTs = './src/migrations'; } } registerDefaultListeners() { /* v8 ignore else */ if (!this.options.silent) { const logger = this.config.getLogger(); this.on('migrating', event => logger.log('migrator', `Processing '${event.name}'`, { enabled: true })); this.on('migrated', event => logger.log('migrator', `Applied '${event.name}'`, { enabled: true })); this.on('reverting', event => logger.log('migrator', `Processing '${event.name}'`, { enabled: true })); this.on('reverted', event => logger.log('migrator', `Reverted '${event.name}'`, { enabled: true })); } } async emit(event, data) { for (const listener of this.#listeners.get(event) ?? []) { await listener(data); } } async discoverMigrations() { if (this.options.migrationsList) { return this.options.migrationsList.map(migration => { if (typeof migration === 'function') { return this.initialize(migration, migration.name); } return this.initialize(migration.class, migration.name); }); } const { fs } = await import('@mikro-orm/core/fs-utils'); const pattern = fs.normalizePath(this.absolutePath, this.options.glob); const files = fs.glob(pattern).sort(); return files.map(filePath => this.resolve({ name: filePath.replace(/\\/g, '/').split('/').pop(), path: filePath, })); } async executeMigrations(method, options = {}) { const all = await this.discoverMigrations(); const executed = await this.storage.executed(); const executedSet = new Set(executed); let toRun; if (method === 'up') { toRun = this.filterUp(all, executedSet, options); } else { toRun = this.filterDown(all, executed, options); } const result = []; const eventBefore = method === 'up' ? 'migrating' : 'reverting'; const eventAfter = method === 'up' ? 'migrated' : 'reverted'; for (const migration of toRun) { const event = { name: migration.name, path: migration.path }; await this.emit(eventBefore, event); // log inside the runner's tx (if any) so a logMigration failure rolls the migration back await migration[method](async (tx) => { if (method === 'up') { await this.storage.logMigration({ name: migration.name }, tx); } else { await this.storage.unlogMigration({ name: migration.name }, tx); } }); await this.emit(eventAfter, event); result.push(event); } return result; } filterUp(all, executed, options) { let pending = all.filter(m => !executed.has(m.name)); if (options.migrations) { const set = new Set(options.migrations); return pending.filter(m => set.has(m.name)); } if (options.from) { const idx = all.findIndex(m => m.name === options.from); if (idx >= 0) { const names = new Set(all.slice(idx + 1).map(m => m.name)); pending = pending.filter(m => names.has(m.name)); } } if (options.to && typeof options.to === 'string') { const idx = all.findIndex(m => m.name === options.to); if (idx >= 0) { const names = new Set(all.slice(0, idx + 1).map(m => m.name)); pending = pending.filter(m => names.has(m.name)); } } return pending; } filterDown(all, executed, options) { const migrationMap = new Map(all.map(m => [m.name, m])); const executedReversed = [...executed].reverse(); if (options.migrations) { const set = new Set(options.migrations); return executedReversed .filter(name => set.has(name)) .map(name => migrationMap.get(name)) .filter(Boolean); } if (options.to === 0) { return executedReversed.map(name => migrationMap.get(name)).filter(Boolean); } if (options.to) { const result = []; for (const name of executedReversed) { if (name === String(options.to)) { break; } const m = migrationMap.get(name); if (m) { result.push(m); } } return result; } // Default: revert last 1 if (executedReversed.length > 0) { const m = migrationMap.get(executedReversed[0]); return m ? [m] : []; } return []; } getMigrationFilename(name) { name = name.replace(/\.[jt]s$/, ''); return /^\d{14}$/.exec(name) ? this.options.fileName(name) : name; } prefix(options) { const base = {}; if (this.options.schema) { base.schema = this.options.schema; } if (typeof options === 'string' || Array.isArray(options)) { return { ...base, migrations: Utils.asArray(options).map(name => this.getMigrationFilename(name)) }; } if (!options) { return base; } const result = base; if (options.migrations) { result.migrations = options.migrations.map(name => this.getMigrationFilename(name)); } if (options.from) { result.from = this.getMigrationFilename(String(options.from)); } if (options.to && options.to !== 0) { result.to = this.getMigrationFilename(String(options.to)); } else if (options.to === 0) { result.to = 0; } if (options.schema !== undefined) { result.schema = options.schema; } return result; } async runMigrations(method, options) { await this.init(); const normalized = this.prefix(options); this.runner.setRunSchema?.(normalized.schema); this.storage.setRunSchema?.(normalized.schema); try { if (!this.options.transactional || !this.options.allOrNothing) { return await this.executeMigrations(method, normalized); } if (Utils.isObject(options) && options.transaction) { return await this.runInTransaction(options.transaction, method, normalized); } return await this.driver.getConnection().transactional(trx => this.runInTransaction(trx, method, normalized)); } finally { this.runner.unsetRunSchema?.(); this.storage.unsetRunSchema?.(); } } async runInTransaction(trx, method, options) { this.runner.setMasterMigration(trx); this.storage.setMasterMigration(trx); try { return await this.executeMigrations(method, options); } finally { this.runner.unsetMasterMigration(); this.storage.unsetMasterMigration(); } } }