UNPKG

@deepbrainspace/nx-surrealdb

Version:

NX plugin for SurrealDB migrations with modular architecture

413 lines 18.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MigrationRepository = void 0; const tslib_1 = require("tslib"); const debug_1 = require("../infrastructure/debug"); const fs = tslib_1.__importStar(require("fs/promises")); const path = tslib_1.__importStar(require("path")); class MigrationRepository { constructor(client, schemaPath, dryRun = false) { this.client = client; this.schemaPath = schemaPath; this.dryRun = dryRun; this.debug = debug_1.Debug.scope('migration-repository'); } async initialize() { // Default schema path relative to the plugin const defaultSchemaPath = path.join(__dirname, '../../schema/system_migrations.surql'); const schemaFile = this.schemaPath || defaultSchemaPath; if (this.dryRun) { this.debug.log('🔍 [DRY-RUN] Would initialize system_migrations table'); this.debug.log(` Schema file: ${schemaFile}`); return; } try { // Read the schema file const schemaQuery = await fs.readFile(schemaFile, 'utf8'); // Basic validation to ensure required fields are defined const requiredFields = [ 'number', 'name', 'direction', 'filename', 'path', 'content', 'module', 'status', 'applied_at', ]; for (const field of requiredFields) { if (!schemaQuery.includes(`DEFINE FIELD IF NOT EXISTS ${field} ON system_migrations`)) { throw new Error(`Schema file ${schemaFile} is missing required field: ${field}`); } } // Execute the schema query await this.client.query(schemaQuery); this.debug.log('Initialized system_migrations table'); } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Schema file not found: ${schemaFile}`); } throw new Error(`Failed to initialize system_migrations table: ${error.message}`); } } async canApplyMigration(number, name, requestedDirection) { if (!['up', 'down'].includes(requestedDirection)) { throw new Error("Requested direction must be 'up' or 'down'"); } if (!number || !name) { throw new Error('Number and name are required'); } try { const currentStatus = await this.getLatestMigrationStatus(number, name); if (!currentStatus) { if (requestedDirection === 'up') { return { canApply: true }; } return { canApply: false, reason: `Migration ${number}_${name} has never been run; cannot apply 'down'.`, }; } if (currentStatus.status === 'fail') { if (requestedDirection === 'up') { return { canApply: true }; } return { canApply: false, reason: `Migration ${number}_${name} has failed previously; cannot apply 'down'.`, }; } if (currentStatus.direction === 'up' && requestedDirection === 'up') { return { canApply: false, reason: `Migration ${number}_${name} is already in 'up' state; cannot apply 'up' again.`, }; } if (currentStatus.direction === 'down' && requestedDirection === 'down') { return { canApply: false, reason: `Migration ${number}_${name} is already in 'down' state; cannot apply 'down' again.`, }; } return { canApply: true }; } catch (error) { throw new Error(`Failed to check migration applicability: ${error.message}`); } } async getLatestMigrationStatus(number, name) { if (!number || !name) { throw new Error('Number and name are required'); } const params = { number, name }; const query = ` SELECT path, status, applied_at, direction, filename FROM system_migrations WHERE number = $number AND name = $name ORDER BY applied_at DESC LIMIT 1 `; try { this.debug.log(`Querying latest migration status for ${number}_${name}`); this.debug.log(`Query: ${query}`); this.debug.log(`Params:`, params); const result = await this.client.query(query, params); this.debug.log(`Raw query result:`, JSON.stringify(result, null, 2)); const migration = result[0][0] ?? null; this.debug.log(`Parsed migration result:`, migration); return migration; } catch (error) { this.debug.error(`Failed to fetch migration status for ${number}_${name}:`, error); throw new Error(`Failed to fetch migration status: ${error.message}`); } } async addMigration(migration) { await this.initialize(); if (!migration.number || !migration.name || !migration.filename || !migration.path || !migration.content) { throw new Error('Missing required migration fields'); } if (!['up', 'down'].includes(migration.direction || 'up')) { throw new Error("Direction must be 'up' or 'down'"); } if (!['success', 'fail'].includes(migration.status || 'success')) { throw new Error("Status must be 'success' or 'fail'"); } if (!/^[a-zA-Z0-9_/.-]+$/.test(migration.path)) { throw new Error('Invalid path format'); } const migrationData = { number: migration.number, name: migration.name, direction: migration.direction ?? 'up', filename: migration.filename, path: migration.path, content: migration.content, module: migration.module, checksum: migration.checksum, status: migration.status ?? 'success', applied_by: this.client.username, execution_time_ms: migration.execution_time_ms, }; if (this.dryRun) { this.debug.log('🔍 [DRY-RUN] Would record migration execution:'); this.debug.log(` Migration: ${migration.number}_${migration.name}_${migration.direction}.surql`); this.debug.log(` Module: ${migration.module}`); this.debug.log(` Status: ${migration.status}`); this.debug.log(` Direction: ${migration.direction}`); return; } await this.client.create('system_migrations', migrationData); } async getMigrationStatus(direction, number, name, filename) { if (!['up', 'down'].includes(direction)) { throw new Error("Direction must be 'up' or 'down'"); } if (!number || !name) { throw new Error('Number and name are required'); } if (filename && !/^[a-zA-Z0-9_/.-]+$/.test(filename)) { throw new Error('Invalid filename format'); } const params = { direction, number, name }; let query = ` SELECT * FROM system_migrations WHERE number = $number AND name = $name ORDER BY applied_at DESC LIMIT 1 `; if (filename) { query = ` SELECT * FROM system_migrations WHERE number = $number AND name = $name AND filename = $filename ORDER BY applied_at DESC LIMIT 1 `; params.filename = filename; } try { const result = await this.client.query(query, params); const migration = result[0]?.[0]; if (!migration) return null; const migrationData = migration; return { id: migrationData.id, number: migrationData.number, name: migrationData.name, direction: migrationData.direction, filename: migrationData.filename, path: migrationData.path, content: migrationData.content, module: migrationData.module, checksum: migrationData.checksum, status: migrationData.status, namespace: migrationData.namespace, database: migrationData.database, applied_at: migrationData.applied_at, applied_by: migrationData.applied_by, execution_time_ms: migrationData.execution_time_ms, }; } catch (error) { throw new Error(`Failed to fetch migration status: ${error.message}`); } } async getMigrationsByDirectionAndPath(direction, path) { if (!['up', 'down'].includes(direction)) { throw new Error("Direction must be 'up' or 'down'"); } if (!path || !/^[a-zA-Z0-9_/.-]+$/.test(path)) { throw new Error('Invalid or missing path'); } try { this.debug.log(`Querying migrations by direction='${direction}' and path='${path}'`); const result = await this.client.query(` SELECT * FROM system_migrations WHERE direction = $direction AND path = $path ORDER BY number ASC `, { direction, path }); this.debug.log(`Raw query result:`, JSON.stringify(result, null, 2)); this.debug.log(`Accessing result[0]:`, result[0]); this.debug.log(`Accessing result[0]?.result:`, result[0]?.result); this.debug.log(`result[0] length:`, Array.isArray(result[0]) ? result[0].length : 'not array'); this.debug.log(`result[0]?.result length:`, Array.isArray(result[0]?.result) ? result[0].result.length : 'not array'); const migrations = (result[0] || []).map((m) => ({ id: m.id, number: m.number, name: m.name, direction: m.direction, filename: m.filename, path: m.path, content: m.content, checksum: m.checksum, status: m.status, namespace: m.namespace, database: m.database, applied_at: m.applied_at, applied_by: m.applied_by, execution_time_ms: m.execution_time_ms, })); this.debug.log(`Mapped migrations count: ${migrations.length}`); if (migrations.length > 0) { this.debug.log(`Sample migration:`, migrations[0]); } return migrations; } catch (error) { this.debug.error(`Failed to fetch migrations for direction='${direction}' path='${path}':`, error); throw new Error(`Failed to fetch migrations: ${error.message}`); } } async findLastMigrations(moduleIds) { if (moduleIds.length === 0) { return []; } try { this.debug.log(`Finding last migrations for modules: [${moduleIds.join(', ')}]`); // Use SurrealDB's GROUP BY to get the latest record per migration file automatically const result = await this.client.query(` SELECT id, module, name, number, direction, status, applied_at, filename, path, content, checksum, namespace, database, applied_by, execution_time_ms FROM system_migrations WHERE module IN [${moduleIds.map(m => `'${m}'`).join(', ')}] GROUP BY module, name, number ORDER BY applied_at DESC `); const migrations = result[0] || []; this.debug.log(`Found ${migrations.length} latest migration records across modules`); // Filter to only include migrations where the latest record is UP + SUCCESS (case insensitive) const eligibleMigrations = migrations.filter((migration) => migration.direction?.toLowerCase() === 'up' && migration.status?.toLowerCase() === 'success'); this.debug.log(`Found ${eligibleMigrations.length} migrations eligible for rollback (latest record is UP+SUCCESS)`); // Convert to Migration interface format const result_migrations = eligibleMigrations.map(m => ({ id: m.id, number: m.number, name: m.name, direction: m.direction, filename: m.filename || '', path: m.path || '', content: m.content || '', module: m.module, checksum: m.checksum, status: m.status, namespace: m.namespace, database: m.database, applied_at: m.applied_at, applied_by: m.applied_by, execution_time_ms: m.execution_time_ms, })); // Sort migrations by number in descending order within each module // Preserve the module order that was passed in (important for rollback order) const moduleOrder = new Map(); moduleIds.forEach((moduleId, index) => { moduleOrder.set(moduleId, index); }); result_migrations.sort((a, b) => { // First sort by module order (preserve the order passed in) const aOrder = moduleOrder.get(a.module) ?? 999; const bOrder = moduleOrder.get(b.module) ?? 999; if (aOrder !== bOrder) return aOrder - bOrder; // Then sort by number in descending order (newest first) return b.number.localeCompare(a.number); }); this.debug.log(`Returning ${result_migrations.length} eligible migrations for rollback (preserving module order, number DESC)`); return result_migrations; } catch (error) { this.debug.error(`Failed to fetch last migrations for modules [${moduleIds.join(', ')}]:`, error); throw new Error(`Failed to fetch last migrations: ${error.message}`); } } async updateMigrationStatus(recordId, status) { if (!recordId.startsWith('system_migrations:') || recordId.length < 18) { throw new Error('Invalid record ID format. Expected system_migrations:<uuid>'); } try { const result = await this.client.query(` UPDATE $id SET status = $status, applied_at = $applied_at `, { id: recordId, status: status, applied_at: new Date().toISOString(), }); if (!result[0]?.result?.length) { throw new Error(`No record found with ID ${recordId}`); } } catch (error) { throw new Error(`Failed to update migration status: ${error.message}`); } } async getAllModuleStatusCounts(moduleIds) { try { const targetDescription = moduleIds && moduleIds.length > 0 ? `specific modules: [${moduleIds.join(', ')}]` : 'all modules'; this.debug.log(`Getting status counts for ${targetDescription}`); // Get latest status per migration file using GROUP BY - filter by modules if specified const whereClause = moduleIds && moduleIds.length > 0 ? `WHERE module IN [${moduleIds.map(m => `'${m}'`).join(', ')}]` : ''; const result = await this.client.query(` SELECT module, name, number, direction, status, applied_at FROM system_migrations ${whereClause} GROUP BY module, name, number ORDER BY applied_at DESC `); const migrations = result[0] || []; this.debug.log(`Found ${migrations.length} migration records for ${targetDescription}`); // Group by module and calculate counts const moduleStats = new Map(); // Group migrations by module const migrationsByModule = new Map(); const migrationsArray = Array.isArray(migrations) ? migrations : []; for (const migration of migrationsArray) { const moduleId = migration.module; if (!migrationsByModule.has(moduleId)) { migrationsByModule.set(moduleId, []); } migrationsByModule.get(moduleId).push(migration); } // Calculate stats for each module for (const [moduleId, moduleMigrations] of migrationsByModule.entries()) { // Count applied migrations (latest status is UP + SUCCESS, case insensitive) const appliedMigrations = moduleMigrations.filter(migration => migration.direction?.toLowerCase() === 'up' && migration.status?.toLowerCase() === 'success'); // Find the most recent applied migration date let lastApplied; if (appliedMigrations.length > 0) { const sortedByDate = appliedMigrations.sort((a, b) => new Date(b.applied_at).getTime() - new Date(a.applied_at).getTime()); lastApplied = new Date(sortedByDate[0].applied_at); } moduleStats.set(moduleId, { appliedCount: appliedMigrations.length, totalCount: moduleMigrations.length, lastApplied, }); this.debug.log(`Module ${moduleId}: ${appliedMigrations.length}/${moduleMigrations.length} applied`); } return moduleStats; } catch (error) { this.debug.error(`Failed to get status counts:`, error); throw new Error(`Failed to get module status counts: ${error.message}`); } } } exports.MigrationRepository = MigrationRepository; //# sourceMappingURL=migration-repository.js.map