import { Injectable, Logger } from '@nestjs/common'; import { Knex } from 'knex'; export interface CustomMigrationRecord { id: string; tenantId: string; name: string; description: string; type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom'; sql: string; status: 'pending' | 'executed' | 'failed'; executedAt?: Date; error?: string; createdAt: Date; updatedAt: Date; } @Injectable() export class CustomMigrationService { private readonly logger = new Logger(CustomMigrationService.name); /** * Generate SQL to create a table with standard fields */ generateCreateTableSQL( tableName: string, fields: { apiName: string; type: string; isRequired?: boolean; isUnique?: boolean; defaultValue?: string; }[] = [], ): string { // Start with standard fields const columns: string[] = [ '`id` VARCHAR(36) PRIMARY KEY', '`ownerId` VARCHAR(36)', '`name` VARCHAR(255)', '`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP', '`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', ]; // Add custom fields for (const field of fields) { const column = this.fieldToColumn(field); columns.push(column); } // Add foreign key and index for ownerId columns.push('INDEX `idx_owner` (`ownerId`)'); return `CREATE TABLE IF NOT EXISTS \`${tableName}\` ( ${columns.join(',\n ')} )`; } /** * Convert field definition to SQL column definition */ private fieldToColumn(field: { apiName: string; type: string; isRequired?: boolean; isUnique?: boolean; defaultValue?: string; }): string { const columnName = field.apiName; let columnDef = `\`${columnName}\``; // Map field types to SQL types switch (field.type.toUpperCase()) { case 'TEXT': case 'STRING': columnDef += ' VARCHAR(255)'; break; case 'LONG_TEXT': columnDef += ' LONGTEXT'; break; case 'NUMBER': case 'DECIMAL': columnDef += ' DECIMAL(18, 2)'; break; case 'INTEGER': columnDef += ' INT'; break; case 'BOOLEAN': columnDef += ' BOOLEAN DEFAULT FALSE'; break; case 'DATE': columnDef += ' DATE'; break; case 'DATE_TIME': columnDef += ' DATETIME'; break; case 'EMAIL': columnDef += ' VARCHAR(255)'; break; case 'URL': columnDef += ' VARCHAR(2048)'; break; case 'PHONE': columnDef += ' VARCHAR(20)'; break; case 'CURRENCY': columnDef += ' DECIMAL(18, 2)'; break; case 'PERCENT': columnDef += ' DECIMAL(5, 2)'; break; case 'PICKLIST': case 'MULTI_PICKLIST': columnDef += ' VARCHAR(255)'; break; case 'LOOKUP': case 'BELONGS_TO': columnDef += ' VARCHAR(36)'; break; default: columnDef += ' VARCHAR(255)'; } // Add constraints if (field.isRequired) { columnDef += ' NOT NULL'; } else { columnDef += ' NULL'; } if (field.isUnique) { columnDef += ' UNIQUE'; } if (field.defaultValue !== undefined && field.defaultValue !== null) { columnDef += ` DEFAULT '${field.defaultValue}'`; } return columnDef; } /** * Create a custom migration record in the database */ async createMigrationRecord( tenantKnex: Knex, data: { tenantId: string; name: string; description: string; type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom'; sql: string; }, ): Promise { // Ensure custom_migrations table exists await this.ensureMigrationsTable(tenantKnex); const id = require('crypto').randomUUID(); const now = new Date(); await tenantKnex('custom_migrations').insert({ id, tenantId: data.tenantId, name: data.name, description: data.description, type: data.type, sql: data.sql, status: 'pending', created_at: now, updated_at: now, }); return tenantKnex('custom_migrations').where({ id }).first(); } /** * Execute a pending migration and update its status */ async executeMigration( tenantKnex: Knex, migrationId: string, ): Promise { try { // Get the migration record const migration = await tenantKnex('custom_migrations') .where({ id: migrationId }) .first(); if (!migration) { throw new Error(`Migration ${migrationId} not found`); } if (migration.status === 'executed') { this.logger.log(`Migration ${migrationId} already executed`); return migration; } // Execute the SQL this.logger.log(`Executing migration: ${migration.name}`); await tenantKnex.raw(migration.sql); // Update status const now = new Date(); await tenantKnex('custom_migrations') .where({ id: migrationId }) .update({ status: 'executed', executedAt: now, updated_at: now, }); this.logger.log(`Migration ${migration.name} executed successfully`); return tenantKnex('custom_migrations').where({ id: migrationId }).first(); } catch (error) { this.logger.error(`Failed to execute migration ${migrationId}:`, error); // Update status with error const now = new Date(); await tenantKnex('custom_migrations') .where({ id: migrationId }) .update({ status: 'failed', error: error.message, updated_at: now, }); throw error; } } /** * Create and execute a migration in one step */ async createAndExecuteMigration( tenantKnex: Knex, tenantId: string, data: { name: string; description: string; type: 'create_table' | 'add_column' | 'alter_column' | 'add_index' | 'drop_table' | 'custom'; sql: string; }, ): Promise { // Create the migration record const migration = await this.createMigrationRecord(tenantKnex, { tenantId, ...data, }); // Execute it immediately return this.executeMigration(tenantKnex, migration.id); } /** * Ensure the custom_migrations table exists in the tenant database */ private async ensureMigrationsTable(tenantKnex: Knex): Promise { const hasTable = await tenantKnex.schema.hasTable('custom_migrations'); if (!hasTable) { await tenantKnex.schema.createTable('custom_migrations', (table) => { table.uuid('id').primary(); table.uuid('tenantId').notNullable(); table.string('name', 255).notNullable(); table.text('description'); table.enum('type', ['create_table', 'add_column', 'alter_column', 'add_index', 'drop_table', 'custom']).notNullable(); table.text('sql').notNullable(); table.enum('status', ['pending', 'executed', 'failed']).defaultTo('pending'); table.timestamp('executedAt').nullable(); table.text('error').nullable(); table.timestamps(true, true); table.index(['tenantId']); table.index(['status']); table.index(['created_at']); }); this.logger.log('Created custom_migrations table'); } } /** * Get all migrations for a tenant */ async getMigrations( tenantKnex: Knex, tenantId: string, filter?: { status?: 'pending' | 'executed' | 'failed'; type?: string; }, ): Promise { await this.ensureMigrationsTable(tenantKnex); let query = tenantKnex('custom_migrations').where({ tenantId }); if (filter?.status) { query = query.where({ status: filter.status }); } if (filter?.type) { query = query.where({ type: filter.type }); } return query.orderBy('created_at', 'asc'); } }