@deepbrainspace/nx-surrealdb
Version:
NX plugin for SurrealDB migrations with modular architecture
622 lines • 30.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MigrationService = void 0;
const tslib_1 = require("tslib");
const fs = tslib_1.__importStar(require("fs/promises"));
const path = tslib_1.__importStar(require("path"));
const crypto = tslib_1.__importStar(require("crypto"));
const perf_hooks_1 = require("perf_hooks");
const client_1 = require("../infrastructure/client");
const migration_repository_1 = require("./migration-repository");
const module_lock_manager_1 = require("./module-lock-manager");
const dependency_resolver_1 = require("./dependency-resolver");
const migration_file_processor_1 = require("../filesystem/migration-file-processor");
const pattern_resolver_1 = require("../filesystem/pattern-resolver");
const env_1 = require("../infrastructure/env");
const project_1 = require("../infrastructure/project");
const debug_1 = require("../infrastructure/debug");
class MigrationService {
constructor(projectContext) {
this.projectContext = projectContext;
this.context = null;
this.options = null;
this.patternResolver = null;
this.debug = debug_1.Debug.scope('migration-service');
}
// Static utility methods for file operations
static async findMatchingSubdirectory(basePath, pattern) {
return migration_file_processor_1.MigrationFileProcessor.findMatchingSubdirectory(basePath, pattern);
}
async initialize(options) {
// Store options for later use
this.options = options;
// Enable debug mode if requested
debug_1.Debug.setEnabled(!!options.debug);
// Load environment variables
if (this.projectContext && options.envFile) {
(0, env_1.loadEnvFile)(this.projectContext, options.envFile);
}
// Resolve options with environment variables
const resolvedOptions = {
url: options.url ? (0, env_1.replaceEnvVars)(options.url) : process.env.SURREALDB_URL,
user: options.user ? (0, env_1.replaceEnvVars)(options.user) : process.env.SURREALDB_ROOT_USER,
pass: options.pass ? (0, env_1.replaceEnvVars)(options.pass) : process.env.SURREALDB_ROOT_PASS,
namespace: options.namespace
? (0, env_1.replaceEnvVars)(options.namespace)
: process.env.SURREALDB_NAMESPACE || 'default',
database: options.database
? (0, env_1.replaceEnvVars)(options.database)
: process.env.SURREALDB_DATABASE || 'default',
useTransactions: options.useTransactions ?? true,
initPath: options.initPath
? (0, env_1.replaceEnvVars)(options.initPath)
: process.env.MIGRATIONS_PATH || 'database',
schemaPath: options.schemaPath ? (0, env_1.replaceEnvVars)(options.schemaPath) : undefined,
force: options.force || false,
configPath: options.configPath,
debug: options.debug || false,
dryRun: options.dryRun || false,
};
if (!resolvedOptions.url || !resolvedOptions.user || !resolvedOptions.pass) {
throw new Error('Missing required configuration. Provide either through options or environment variables.');
}
// Resolve project path if we have project context
const basePath = this.projectContext
? (0, project_1.resolveProjectPath)(this.projectContext, resolvedOptions.initPath)
: resolvedOptions.initPath;
// Initialize database client with dry-run flag
const client = new client_1.SurrealDBClient(resolvedOptions.dryRun);
await client.connect({
url: resolvedOptions.url,
username: resolvedOptions.user,
password: resolvedOptions.pass,
namespace: resolvedOptions.namespace,
database: resolvedOptions.database,
});
// Initialize migration repository with dry-run flag
const repository = new migration_repository_1.MigrationRepository(client, resolvedOptions.schemaPath, resolvedOptions.dryRun);
await repository.initialize();
// Initialize dependency resolver
const resolver = new dependency_resolver_1.DependencyResolver(basePath);
await resolver.initialize(resolvedOptions.configPath);
// Initialize pattern resolver
this.patternResolver = new pattern_resolver_1.PatternResolver(resolver, basePath);
this.context = {
client,
repository,
resolver,
options: resolvedOptions,
};
}
async findPendingMigrations(targetModules, direction = 'up') {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
this.debug.log('findPendingMigrations:', targetModules);
const { resolver, options, repository } = this.context;
const basePath = this.projectContext
? (0, project_1.resolveProjectPath)(this.projectContext, options.initPath)
: options.initPath;
// Determine which modules to process
const modulesToProcess = targetModules && targetModules.length > 0
? this.resolveTargetModules(targetModules)
: resolver.getAllModules();
// Get execution order based on dependencies
const executionOrder = direction === 'up'
? resolver.getExecutionOrder(modulesToProcess)
: resolver.getRollbackOrder(modulesToProcess);
const pendingMigrations = [];
for (const moduleId of executionOrder) {
const moduleFiles = await this.findModuleMigrations(moduleId, direction, basePath);
for (const file of moduleFiles) {
// Check if migration should be applied
const { canApply } = await repository.canApplyMigration(file.number, file.name, direction);
if (canApply || options.force) {
pendingMigrations.push(file);
}
}
}
return pendingMigrations;
}
async executeMigrations(targetModules, operation = 'migrate', targetFilenames) {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
const startTime = perf_hooks_1.performance.now();
// For rollback, find last applied migrations instead of pending migrations
let migrationsToProcess;
if (operation === 'rollback') {
// If no target modules specified, get all modules for rollback
let modulesToRollback;
if (targetModules && targetModules.length > 0) {
// For specific modules, resolve them first
const resolvedModules = this.resolveTargetModules(targetModules);
const resolvedSet = new Set(resolvedModules);
// Get the full rollback order, then filter to only requested modules
const allModules = this.context.resolver.getAllModules();
const fullRollbackOrder = this.context.resolver.getRollbackOrder(allModules);
// Keep only the modules that were specifically requested, but in rollback order
modulesToRollback = fullRollbackOrder.filter(module => resolvedSet.has(module));
this.debug.log(`Specific modules requested for rollback (in order): ${modulesToRollback.join(', ')}`);
}
else {
// For full rollback, get all modules in proper rollback order
const allModules = this.context.resolver.getAllModules();
modulesToRollback = this.context.resolver.getRollbackOrder(allModules);
this.debug.log(`Rolling back all modules in order: ${modulesToRollback.join(', ')}`);
}
const appliedMigrations = await this.findLastMigrations(modulesToRollback);
// Find corresponding down files for each applied migration
const { options } = this.context;
const basePath = this.projectContext
? (0, project_1.resolveProjectPath)(this.projectContext, options.initPath)
: options.initPath;
migrationsToProcess = [];
this.debug.log(`Processing ${appliedMigrations.length} applied migrations for rollback`);
for (const migration of appliedMigrations) {
const downFilename = `${migration.number}_${migration.name}_down.surql`;
const downFilePath = path.join(basePath, migration.module, downFilename);
this.debug.log(`Looking for down file: ${downFilePath}`);
try {
const content = await fs.readFile(downFilePath, 'utf-8');
const checksum = crypto.createHash('sha256').update(content).digest('hex');
this.debug.log(`Successfully loaded down file: ${downFilename}`);
migrationsToProcess.push({
number: migration.number,
name: migration.name,
direction: 'down',
filename: downFilename,
filePath: downFilePath,
moduleId: migration.module,
content,
checksum,
});
}
catch (error) {
this.debug.log(`Warning: Down file not found for ${migration.number}_${migration.name} in ${migration.module}: ${downFilePath}`);
this.debug.log(`Error details: ${error.message}`);
}
}
// Filter out locked modules unless force is enabled
if (!options.force && operation === 'rollback') {
const config = this.context.resolver.getConfig();
const lockManager = module_lock_manager_1.ModuleLockManager.createLockManager(config);
const originalCount = migrationsToProcess.length;
migrationsToProcess = migrationsToProcess.filter(migration => {
const isLocked = lockManager.isModuleLocked(migration.moduleId);
if (isLocked) {
this.debug.log(`Skipping locked module migration: ${migration.moduleId}/${migration.filename}`);
}
return !isLocked;
});
const filteredCount = originalCount - migrationsToProcess.length;
if (filteredCount > 0) {
this.debug.log(`Filtered out ${filteredCount} migrations from locked modules`);
}
}
this.debug.log(`Total migrations to process: ${migrationsToProcess.length}`);
}
else {
migrationsToProcess = await this.findPendingMigrations(targetModules, 'up');
}
// Apply filename filtering if specified
if (targetFilenames && targetFilenames.length > 0) {
const resolvedFilenames = await this.resolveTargetFilenames(targetFilenames, targetModules, operation === 'rollback' ? 'down' : 'up');
// Filter migrations to only include resolved filenames
const filenameSet = new Set(resolvedFilenames.map(rf => rf.filename));
migrationsToProcess = migrationsToProcess.filter(m => filenameSet.has(m.filename));
this.debug.log(`Filtered migrations by filename: ${migrationsToProcess.length} remaining`);
}
if (migrationsToProcess.length === 0) {
this.debug.log(`No migrations to process - returning early`);
return {
success: true,
filesProcessed: 0,
filesSkipped: 0,
executionTimeMs: Math.round(perf_hooks_1.performance.now() - startTime),
results: [],
};
}
this.debug.log(`Starting execution of ${migrationsToProcess.length} migrations`);
const results = [];
let filesProcessed = 0;
let filesSkipped = 0;
this.debug.log(`Processing ${migrationsToProcess.length} migration files`);
for (const migrationFile of migrationsToProcess) {
this.debug.log(`Executing migration: ${migrationFile.filename}`);
const result = await this.executeSingleMigration(migrationFile);
results.push(result);
this.debug.log(`Migration ${migrationFile.filename} result: success=${result.success}, skipped=${result.skipped}`);
if (result.skipped) {
filesSkipped++;
this.debug.log(`Incremented skipped count to: ${filesSkipped}`);
}
else if (result.success) {
filesProcessed++;
this.debug.log(`Incremented processed count to: ${filesProcessed}`);
}
else {
this.debug.log(`Migration failed: ${result.error}`);
// If a migration fails and we're not forcing, stop execution
if (!this.context.options.force) {
this.debug.log(`Stopping execution due to failure (force=${this.context.options.force})`);
break;
}
}
}
const executionTimeMs = Math.round(perf_hooks_1.performance.now() - startTime);
const finalSuccess = results.every(r => r.success || r.skipped);
this.debug.log(`Final result: success=${finalSuccess}, filesProcessed=${filesProcessed}, filesSkipped=${filesSkipped}, results.length=${results.length}`);
return {
success: finalSuccess,
filesProcessed,
filesSkipped,
executionTimeMs,
results,
};
}
async validateRollback(targetModules) {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
const { resolver, options } = this.context;
const basePath = this.projectContext
? (0, project_1.resolveProjectPath)(this.projectContext, options.initPath)
: options.initPath;
// Resolve target modules to handle patterns like "010" -> "010_auth"
const resolvedModules = targetModules
? this.resolveTargetModules(targetModules)
: resolver.getAllModules();
// Initialize result collectors
const warnings = [];
const blockedBySet = new Set();
const migrationChecks = [];
let canRollback = true;
// Validate each module
for (const moduleId of resolvedModules) {
// 1. Check dependency conflicts
const dependencyValidation = resolver.validateRollback(moduleId, resolvedModules);
if (!dependencyValidation.canRollback) {
// Check if the blockers actually have applied migrations
const actualBlockers = [];
for (const blocker of dependencyValidation.blockedBy) {
const blockerMigrations = await this.getAppliedMigrations(blocker);
if (blockerMigrations.length > 0) {
actualBlockers.push(blocker);
}
}
if (actualBlockers.length > 0) {
canRollback = false;
actualBlockers.forEach(blocker => blockedBySet.add(blocker));
warnings.push(`Cannot rollback ${moduleId} because it has active dependents with applied migrations: ${actualBlockers.join(', ')}`);
}
// If no actual blockers (all dependents have 0 applied migrations), allow rollback
}
// 2. Get migration state
const [appliedMigrations, rollbackFiles] = await Promise.all([
this.getAppliedMigrations(moduleId),
this.findModuleMigrations(moduleId, 'down', basePath),
]);
const hasAppliedMigrations = appliedMigrations.length > 0;
const hasRollbackFiles = rollbackFiles.length > 0;
// 3. Validate rollback file availability
if (hasAppliedMigrations && !hasRollbackFiles) {
canRollback = false;
warnings.push(`Module ${moduleId} has applied migrations but no rollback files available`);
}
// 4. Validate each applied migration has a corresponding rollback file
if (hasAppliedMigrations && hasRollbackFiles) {
const rollbackFileMap = new Map(rollbackFiles.map(rf => [`${rf.number}_${rf.name}`, rf]));
for (const applied of appliedMigrations) {
const migrationKey = `${applied.number}_${applied.name}`;
if (!rollbackFileMap.has(migrationKey)) {
canRollback = false;
warnings.push(`Missing rollback file for migration ${migrationKey} in ${moduleId}`);
}
}
}
// 5. Record migration check results
migrationChecks.push({
moduleId,
hasAppliedMigrations,
rollbackFilesAvailable: hasRollbackFiles,
dependencyConflicts: dependencyValidation.blockedBy,
});
}
// Check for locked modules ONLY if there are applied migrations to rollback and force is not enabled
if (!options.force && canRollback) {
const modulesWithAppliedMigrations = migrationChecks.filter(check => check.hasAppliedMigrations);
if (modulesWithAppliedMigrations.length > 0) {
const config = resolver.getConfig();
const lockManager = module_lock_manager_1.ModuleLockManager.createLockManager(config);
const modulesToCheck = modulesWithAppliedMigrations.map(check => check.moduleId);
const lockValidation = lockManager.validateRollbackLock(modulesToCheck);
if (lockValidation.blockedModules.length > 0) {
// Add warnings for locked modules but don't block the operation
for (const blockedModule of lockValidation.blockedModules) {
const reason = lockValidation.lockReasons[blockedModule];
warnings.push(`🔒 Module ${blockedModule} is locked and will be skipped: ${reason}`);
}
}
}
}
// Handle force flag override
if (options.force && !canRollback) {
warnings.push('WARNING: Force flag enabled - bypassing rollback safety checks');
canRollback = true;
}
return {
canRollback,
blockedBy: Array.from(blockedBySet),
warnings,
migrationChecks,
};
}
async getMigrationStatus(targetModules) {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
const { resolver, repository } = this.context;
const modulesToCheck = targetModules
? this.resolveTargetModules(targetModules)
: resolver.getAllModules();
// Get all status counts in one query
const dbStatusMap = await repository.getAllModuleStatusCounts(modulesToCheck);
const modules = [];
let totalApplied = 0;
let totalPending = 0;
for (const moduleId of modulesToCheck) {
const dependencies = resolver.getModuleDependencies(moduleId);
const dependents = resolver.getModuleDependents(moduleId);
// Get filesystem count and database counts
const statusCounts = await this.getModuleStatusCounts(moduleId);
const dbStatus = dbStatusMap.get(moduleId);
const appliedMigrations = dbStatus?.appliedCount || 0;
const pendingMigrations = statusCounts.totalFileCount - appliedMigrations;
const lastApplied = dbStatus?.lastApplied;
modules.push({
moduleId,
appliedMigrations,
pendingMigrations,
lastApplied,
dependencies,
dependents,
});
totalApplied += appliedMigrations;
totalPending += pendingMigrations;
}
return {
modules,
totalApplied,
totalPending,
};
}
async getFileStatus(resolvedFilenames) {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
const { repository, resolver } = this.context;
const fileStatuses = [];
for (const file of resolvedFilenames) {
// Check if migration is applied
const migrationStatus = await repository.getLatestMigrationStatus(file.number, file.name);
const isApplied = migrationStatus && migrationStatus.status === 'success';
// Get module dependencies and dependents
const dependencies = resolver.getModuleDependencies(file.moduleId);
const dependents = resolver.getModuleDependents(file.moduleId);
fileStatuses.push({
filename: file.filename,
moduleId: file.moduleId,
number: file.number,
name: file.name,
direction: file.direction,
status: isApplied ? 'applied' : 'pending',
appliedAt: migrationStatus?.applied_at ? new Date(migrationStatus.applied_at) : undefined,
checksum: migrationStatus?.checksum,
dependencies,
dependents,
});
}
return fileStatuses;
}
buildMigrationRecord(migrationFile, status, executionTimeMs) {
const { options } = this.context;
return {
number: migrationFile.number,
name: migrationFile.name,
direction: migrationFile.direction,
filename: migrationFile.filename,
path: path.dirname(migrationFile.filePath),
content: migrationFile.content,
module: migrationFile.moduleId,
namespace: options.namespace,
database: options.database,
checksum: migrationFile.checksum,
status,
execution_time_ms: executionTimeMs,
};
}
async findLastMigrations(moduleIds) {
if (!this.context) {
throw new Error('Migration engine not initialized. Call initialize() first.');
}
return await this.context.repository.findLastMigrations(moduleIds);
}
async close() {
if (this.context) {
await this.context.client.close();
this.context = null;
}
}
resolveTargetModules(targetModules) {
if (!this.patternResolver) {
throw new Error('Migration engine not initialized.');
}
const result = this.patternResolver.resolveModules(targetModules);
if (result.notFound.length > 0) {
const allModules = this.context?.resolver.getAllModules() || [];
throw new Error(`Module(s) not found: ${result.notFound.join(', ')}. Available modules: ${allModules.join(', ')}`);
}
return result.resolved.map(r => r.moduleId);
}
async resolveTargetFilenames(filenamePatterns, targetModules, direction = 'up') {
if (!this.patternResolver) {
throw new Error('Migration engine not initialized.');
}
// Resolve module patterns first if provided
const resolvedTargetModules = targetModules
? this.resolveTargetModules(targetModules)
: undefined;
const result = await this.patternResolver.resolveFilenames(filenamePatterns, resolvedTargetModules, direction);
if (result.notFound.length > 0) {
throw new Error(`Filename(s) not found: ${result.notFound.join(', ')}`);
}
return result.resolved;
}
async resolveRollbackFilenames(filenamePatterns, targetModules) {
if (!this.patternResolver) {
throw new Error('Migration engine not initialized.');
}
// Resolve module patterns first if provided
const resolvedTargetModules = targetModules
? this.resolveTargetModules(targetModules)
: undefined;
const result = await this.patternResolver.resolveRollbackFilenames(filenamePatterns, resolvedTargetModules);
if (result.notFound.length > 0) {
throw new Error(`Rollback filename(s) not found: ${result.notFound.join(', ')}`);
}
return {
resolved: result.resolved,
warnings: result.dependencyWarnings,
};
}
async findModuleMigrations(moduleId, direction, basePath) {
const modulePath = path.join(basePath, moduleId);
try {
const files = await fs.readdir(modulePath);
const migrationFiles = migration_file_processor_1.MigrationFileProcessor.filterMigrationFiles(files, undefined, direction);
const migrations = [];
for (const filename of migrationFiles) {
const filePath = path.join(modulePath, filename);
const content = await fs.readFile(filePath, 'utf-8');
const checksum = crypto.createHash('sha256').update(content).digest('hex');
const fileParts = filename.match(/^(\d+)_(.+)_(up|down)\.surql$/);
if (!fileParts)
continue;
const [, number, name] = fileParts;
migrations.push({
number,
name,
direction,
filename,
filePath,
moduleId,
content,
checksum,
});
}
return migrations.sort((a, b) => a.number.localeCompare(b.number));
}
catch {
// Module directory doesn't exist or is empty
return [];
}
}
async executeSingleMigration(migrationFile) {
if (!this.context) {
throw new Error('Migration engine not initialized.');
}
const { client, repository, options } = this.context;
const startTime = perf_hooks_1.performance.now();
try {
// Check if migration can be applied (unless forcing)
if (!options.force) {
const { canApply, reason } = await repository.canApplyMigration(migrationFile.number, migrationFile.name, migrationFile.direction);
if (!canApply) {
return {
file: migrationFile,
success: true,
executionTimeMs: Math.round(perf_hooks_1.performance.now() - startTime),
skipped: true,
skipReason: reason,
};
}
}
// Process migration content
const processedContent = migration_file_processor_1.MigrationFileProcessor.processContent(migrationFile.content, {
defaultNamespace: options.namespace,
defaultDatabase: options.database,
useTransactions: options.useTransactions,
});
if (options.dryRun) {
this.debug.log('🔍 [DRY-RUN] Would execute migration:');
this.debug.log(` File: ${migrationFile.moduleId}/${migrationFile.filename}`);
this.debug.log(` Direction: ${migrationFile.direction}`);
this.debug.log(` Content preview: ${processedContent.substring(0, 200)}${processedContent.length > 200 ? '...' : ''}`);
}
// Execute migration
await client.query(processedContent);
const executionTimeMs = Math.round(perf_hooks_1.performance.now() - startTime);
// Record migration
await repository.addMigration(this.buildMigrationRecord(migrationFile, 'success', executionTimeMs));
return {
file: migrationFile,
success: true,
executionTimeMs,
};
}
catch (error) {
const executionTimeMs = Math.round(perf_hooks_1.performance.now() - startTime);
// Record failed migration
await repository.addMigration(this.buildMigrationRecord(migrationFile, 'fail', executionTimeMs));
return {
file: migrationFile,
success: false,
executionTimeMs,
error: error instanceof Error ? error.message : String(error),
};
}
}
async getModuleStatusCounts(moduleId) {
if (!this.context)
return { appliedCount: 0, totalFileCount: 0 };
// Get total migration files from filesystem
const basePath = this.projectContext
? (0, project_1.resolveProjectPath)(this.projectContext, this.context.options.initPath)
: this.context.options.initPath;
const allMigrationFiles = await this.findModuleMigrations(moduleId, 'up', basePath);
return {
appliedCount: 0, // This will be filled by caller from batch query
totalFileCount: allMigrationFiles.length,
lastApplied: undefined, // This will be filled by caller from batch query
};
}
async getAppliedMigrations(moduleId) {
if (!this.context)
return [];
this.debug.log(`Checking currently applied migrations for module ${moduleId}`);
try {
// Use findLastMigrations to get only migrations where the latest status is UP+SUCCESS
const lastMigrations = await this.context.repository.findLastMigrations([moduleId]);
this.debug.log(`Found ${lastMigrations.length} currently applied migrations for ${moduleId}`);
return lastMigrations.map(migration => ({
number: migration.number || '',
name: migration.name || '',
direction: 'up',
appliedAt: new Date(migration.applied_at || Date.now()),
}));
}
catch (error) {
console.error(`Failed to get applied migrations for ${moduleId}:`, error);
return [];
}
}
getConfig() {
return this.context?.resolver.getConfig() || null;
}
getAllModules() {
return this.context?.resolver.getAllModules() || [];
}
}
exports.MigrationService = MigrationService;
//# sourceMappingURL=migration-service.js.map