UNPKG

supa-seed

Version:

A constraint-aware, framework-agnostic database seeding framework with deep PostgreSQL business logic discovery and MakerKit integration support

731 lines 29.6 kB
"use strict"; /** * Schema Evolution and Change Detection System * Phase 4, Checkpoint D1 - Intelligent schema change detection and migration support */ Object.defineProperty(exports, "__esModule", { value: true }); exports.SchemaEvolutionEngine = void 0; const schema_adapter_1 = require("../core/schema-adapter"); const logger_1 = require("../core/utils/logger"); class SchemaEvolutionEngine { constructor(supabase) { this.snapshots = new Map(); this.supabase = supabase; this.schemaAdapter = new schema_adapter_1.SchemaAdapter(supabase); } /** * Capture a complete schema snapshot */ async captureSchemaSnapshot(version, description) { logger_1.Logger.info('📸 Capturing schema snapshot...'); const timestamp = new Date(); const snapshotId = `snapshot-${timestamp.getTime()}-${Math.random().toString(36).substr(2, 9)}`; const tables = await this.captureTables(); const functions = await this.captureFunctions(); const types = await this.captureTypes(); const extensions = await this.captureExtensions(); const databaseVersion = await this.getDatabaseVersion(); const snapshot = { id: snapshotId, timestamp, version: version || '1.0.0', tables, functions, types, extensions, metadata: { databaseVersion, capturedBy: 'supa-seed-evolution-engine', environment: process.env.NODE_ENV || 'development', description } }; this.snapshots.set(snapshotId, snapshot); logger_1.Logger.info(`✅ Schema snapshot captured: ${snapshotId} (${tables.size} tables, ${functions.size} functions)`); return snapshot; } /** * Compare two schema snapshots to detect changes */ async compareSchemas(beforeSnapshot, afterSnapshot) { logger_1.Logger.info(`🔍 Comparing schemas: ${beforeSnapshot.id} → ${afterSnapshot.id}`); const changes = []; // Compare tables changes.push(...this.compareTableSchemas(beforeSnapshot.tables, afterSnapshot.tables)); // Compare functions changes.push(...this.compareFunctionSchemas(beforeSnapshot.functions, afterSnapshot.functions)); // Compare types changes.push(...this.compareTypeSchemas(beforeSnapshot.types, afterSnapshot.types)); // Compare extensions changes.push(...this.compareExtensions(beforeSnapshot.extensions, afterSnapshot.extensions)); logger_1.Logger.info(`🔍 Schema comparison complete: ${changes.length} changes detected`); return changes; } /** * Analyze the impact of schema changes on existing configurations */ async analyzeConfigurationImpact(changes, configurationPaths = ['supa-seed.config.js', 'seeds/']) { logger_1.Logger.info('📊 Analyzing configuration impact...'); const impacts = []; for (const configPath of configurationPaths) { const impact = await this.analyzeConfigurationFile(configPath, changes); if (impact.changes.length > 0) { impacts.push(impact); } } logger_1.Logger.info(`📊 Configuration impact analysis complete: ${impacts.length} files affected`); return impacts; } /** * Generate migration suggestions based on detected changes */ generateMigrationSuggestions(changes, impacts) { logger_1.Logger.info('🎯 Generating migration suggestions...'); const suggestions = []; // Group changes by type and severity const groupedChanges = this.groupChangesByType(changes); // Generate automatic migrations for safe changes suggestions.push(...this.generateAutomaticMigrations(groupedChanges)); // Generate semi-automatic migrations for moderate changes suggestions.push(...this.generateSemiAutomaticMigrations(groupedChanges, impacts)); // Generate manual migrations for complex changes suggestions.push(...this.generateManualMigrations(groupedChanges, impacts)); // Sort by priority and risk suggestions.sort((a, b) => { const priorityOrder = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 }; const riskOrder = { LOW: 1, MEDIUM: 2, HIGH: 3 }; if (priorityOrder[a.priority] !== priorityOrder[b.priority]) { return priorityOrder[b.priority] - priorityOrder[a.priority]; } return riskOrder[a.riskLevel] - riskOrder[b.riskLevel]; }); logger_1.Logger.info(`🎯 Generated ${suggestions.length} migration suggestions`); return suggestions; } /** * Detect schema changes by comparing current schema with a previous snapshot */ async detectSchemaChanges(previousSnapshotId) { logger_1.Logger.info('🔎 Starting schema change detection...'); // Capture current schema const currentSnapshot = await this.captureSchemaSnapshot(); let changes = []; let impacts = []; let suggestions = []; if (previousSnapshotId && this.snapshots.has(previousSnapshotId)) { const previousSnapshot = this.snapshots.get(previousSnapshotId); // Compare schemas changes = await this.compareSchemas(previousSnapshot, currentSnapshot); if (changes.length > 0) { // Analyze impact on configurations impacts = await this.analyzeConfigurationImpact(changes); // Generate migration suggestions suggestions = this.generateMigrationSuggestions(changes, impacts); } } else { logger_1.Logger.info('No previous snapshot found - this will be the baseline'); } logger_1.Logger.info(`🔎 Schema change detection complete: ${changes.length} changes, ${impacts.length} impacts, ${suggestions.length} suggestions`); return { changes, impacts, suggestions, currentSnapshot }; } /** * Capture all table schemas from the database */ async captureTables() { const tables = new Map(); try { // Get all table names const { data: tableData, error: tableError } = await this.supabase .from('information_schema.tables') .select('table_name') .eq('table_schema', 'public') .eq('table_type', 'BASE TABLE'); if (tableError) { logger_1.Logger.warn(`Failed to fetch tables: ${tableError.message}`); return tables; } // Capture schema for each table for (const table of tableData || []) { try { const tableSchema = await this.captureTableSchema(table.table_name); tables.set(table.table_name, tableSchema); } catch (error) { logger_1.Logger.warn(`Failed to capture schema for table ${table.table_name}: ${error.message}`); } } } catch (error) { logger_1.Logger.warn(`Failed to capture tables: ${error.message}`); } return tables; } /** * Capture schema for a specific table */ async captureTableSchema(tableName) { const [columns, constraints, indexes, triggers, policies, relationships] = await Promise.all([ this.captureTableColumns(tableName), this.captureTableConstraints(tableName), this.captureTableIndexes(tableName), this.captureTableTriggers(tableName), this.captureTablePolicies(tableName), this.captureTableRelationships(tableName) ]); return { name: tableName, columns, constraints, indexes, triggers, policies, relationships, metadata: { created: new Date(), // Would be fetched from pg_class in real implementation modified: new Date(), owner: 'postgres', // Would be fetched from pg_class comment: undefined } }; } /** * Capture column information for a table */ async captureTableColumns(tableName) { const { data, error } = await this.supabase .from('information_schema.columns') .select('*') .eq('table_schema', 'public') .eq('table_name', tableName) .order('ordinal_position'); if (error) { logger_1.Logger.warn(`Failed to fetch columns for ${tableName}: ${error.message}`); return []; } return (data || []).map(col => ({ name: col.column_name, type: col.data_type, nullable: col.is_nullable === 'YES', defaultValue: col.column_default, isPrimaryKey: false, // Would be determined from constraints isForeignKey: false, // Would be determined from constraints isUnique: false, // Would be determined from constraints maxLength: col.character_maximum_length, precision: col.numeric_precision, scale: col.numeric_scale, comment: undefined })); } /** * Capture constraint information for a table */ async captureTableConstraints(tableName) { // This would query information_schema.table_constraints and related views // For now, return empty array as it requires complex SQL queries return []; } /** * Capture index information for a table */ async captureTableIndexes(tableName) { // This would query pg_indexes and related system tables // For now, return empty array as it requires complex SQL queries return []; } /** * Capture trigger information for a table */ async captureTableTriggers(tableName) { // This would query information_schema.triggers // For now, return empty array as it requires complex SQL queries return []; } /** * Capture RLS policy information for a table */ async captureTablePolicies(tableName) { // This would query pg_policies // For now, return empty array as it requires complex SQL queries return []; } /** * Capture relationship information for a table */ async captureTableRelationships(tableName) { // This would analyze foreign key constraints to build relationships // For now, return empty array as it requires complex SQL queries return []; } /** * Capture all function schemas */ async captureFunctions() { // This would query information_schema.routines // For now, return empty map as it requires complex SQL queries return new Map(); } /** * Capture all custom type schemas */ async captureTypes() { // This would query pg_type and related system tables // For now, return empty map as it requires complex SQL queries return new Map(); } /** * Capture installed extensions */ async captureExtensions() { try { const { data, error } = await this.supabase .from('pg_extension') .select('extname'); if (error) { logger_1.Logger.warn(`Failed to fetch extensions: ${error.message}`); return []; } return (data || []).map(ext => ext.extname); } catch (error) { logger_1.Logger.warn(`Failed to capture extensions: ${error.message}`); return []; } } /** * Get database version */ async getDatabaseVersion() { try { const { data, error } = await this.supabase.rpc('version'); if (error) { logger_1.Logger.warn(`Failed to get database version: ${error.message}`); return 'unknown'; } return data || 'unknown'; } catch (error) { logger_1.Logger.warn(`Failed to get database version: ${error.message}`); return 'unknown'; } } /** * Compare table schemas between two snapshots */ compareTableSchemas(beforeTables, afterTables) { const changes = []; // Check for added tables for (const [tableName, tableSchema] of afterTables) { if (!beforeTables.has(tableName)) { changes.push({ id: `table-added-${tableName}`, type: 'TABLE_ADDED', severity: 'MEDIUM', impact: 'COMPATIBLE', tableName, after: tableSchema, description: `Table '${tableName}' was added`, recommendations: [ 'Update seeding configurations to include the new table', 'Consider adding seed data for the new table' ], migrationRequired: true, dataBackupRequired: false }); } } // Check for removed tables for (const [tableName, tableSchema] of beforeTables) { if (!afterTables.has(tableName)) { changes.push({ id: `table-removed-${tableName}`, type: 'TABLE_REMOVED', severity: 'HIGH', impact: 'BREAKING', tableName, before: tableSchema, description: `Table '${tableName}' was removed`, recommendations: [ 'Remove references to this table from seeding configurations', 'Consider data migration if table contained important data' ], migrationRequired: true, dataBackupRequired: true }); } } // Check for modified tables for (const [tableName, afterTable] of afterTables) { if (beforeTables.has(tableName)) { const beforeTable = beforeTables.get(tableName); changes.push(...this.compareTableColumns(tableName, beforeTable.columns, afterTable.columns)); } } return changes; } /** * Compare columns between two table schemas */ compareTableColumns(tableName, beforeColumns, afterColumns) { const changes = []; const beforeColMap = new Map(beforeColumns.map(col => [col.name, col])); const afterColMap = new Map(afterColumns.map(col => [col.name, col])); // Check for added columns for (const [colName, colSchema] of afterColMap) { if (!beforeColMap.has(colName)) { changes.push({ id: `column-added-${tableName}-${colName}`, type: 'COLUMN_ADDED', severity: 'LOW', impact: colSchema.nullable ? 'COMPATIBLE' : 'BREAKING', tableName, columnName: colName, after: colSchema, description: `Column '${colName}' was added to table '${tableName}'`, recommendations: colSchema.nullable ? [ 'No immediate action required - column is nullable' ] : [ 'Update seeding data to provide values for the new non-nullable column', 'Consider adding a default value to the column' ], migrationRequired: !colSchema.nullable, dataBackupRequired: false }); } } // Check for removed columns for (const [colName, colSchema] of beforeColMap) { if (!afterColMap.has(colName)) { changes.push({ id: `column-removed-${tableName}-${colName}`, type: 'COLUMN_REMOVED', severity: 'HIGH', impact: 'BREAKING', tableName, columnName: colName, before: colSchema, description: `Column '${colName}' was removed from table '${tableName}'`, recommendations: [ 'Remove references to this column from seeding configurations', 'Update any asset mappings that use this column' ], migrationRequired: true, dataBackupRequired: true }); } } // Check for modified columns for (const [colName, afterCol] of afterColMap) { if (beforeColMap.has(colName)) { const beforeCol = beforeColMap.get(colName); const colChanges = this.compareColumnProperties(tableName, colName, beforeCol, afterCol); changes.push(...colChanges); } } return changes; } /** * Compare properties of two column schemas */ compareColumnProperties(tableName, columnName, beforeCol, afterCol) { const changes = []; // Check for type changes if (beforeCol.type !== afterCol.type) { changes.push({ id: `column-type-changed-${tableName}-${columnName}`, type: 'COLUMN_MODIFIED', severity: 'HIGH', impact: 'BREAKING', tableName, columnName, before: beforeCol.type, after: afterCol.type, description: `Column '${columnName}' type changed from ${beforeCol.type} to ${afterCol.type}`, recommendations: [ 'Review and update seeding data to match the new column type', 'Test data type conversions to ensure compatibility' ], migrationRequired: true, dataBackupRequired: true }); } // Check for nullable changes if (beforeCol.nullable !== afterCol.nullable) { changes.push({ id: `column-nullable-changed-${tableName}-${columnName}`, type: 'COLUMN_MODIFIED', severity: afterCol.nullable ? 'LOW' : 'MEDIUM', impact: afterCol.nullable ? 'COMPATIBLE' : 'BREAKING', tableName, columnName, before: beforeCol.nullable, after: afterCol.nullable, description: `Column '${columnName}' nullable changed from ${beforeCol.nullable} to ${afterCol.nullable}`, recommendations: afterCol.nullable ? [ 'Column can now accept null values - no immediate action required' ] : [ 'Column now requires non-null values - ensure seeding data provides values', 'Consider adding a default value if appropriate' ], migrationRequired: !afterCol.nullable, dataBackupRequired: false }); } return changes; } /** * Compare function schemas (placeholder) */ compareFunctionSchemas(beforeFunctions, afterFunctions) { // Function comparison would be implemented here return []; } /** * Compare type schemas (placeholder) */ compareTypeSchemas(beforeTypes, afterTypes) { // Type comparison would be implemented here return []; } /** * Compare extensions */ compareExtensions(beforeExts, afterExts) { const changes = []; const beforeSet = new Set(beforeExts); const afterSet = new Set(afterExts); // Check for added extensions for (const ext of afterExts) { if (!beforeSet.has(ext)) { changes.push({ id: `extension-added-${ext}`, type: 'TYPE_ADDED', // Using TYPE_ADDED as placeholder severity: 'LOW', impact: 'COMPATIBLE', objectName: ext, after: ext, description: `Extension '${ext}' was added`, recommendations: ['No action required - extension additions are typically safe'], migrationRequired: false, dataBackupRequired: false }); } } // Check for removed extensions for (const ext of beforeExts) { if (!afterSet.has(ext)) { changes.push({ id: `extension-removed-${ext}`, type: 'TYPE_REMOVED', // Using TYPE_REMOVED as placeholder severity: 'MEDIUM', impact: 'BREAKING', objectName: ext, before: ext, description: `Extension '${ext}' was removed`, recommendations: [ 'Ensure no seeding logic depends on this extension', 'Review any custom functions that might use this extension' ], migrationRequired: true, dataBackupRequired: false }); } } return changes; } /** * Analyze impact on a specific configuration file */ async analyzeConfigurationFile(configPath, changes) { // This would analyze actual configuration files // For now, return a basic impact analysis const affectedChanges = changes.filter(change => change.impact === 'BREAKING' || change.migrationRequired); const configChanges = affectedChanges.map(change => ({ section: 'seeders', field: change.tableName || change.objectName || 'unknown', currentValue: change.before, suggestedValue: change.after, reason: change.description, required: change.migrationRequired, breaking: change.impact === 'BREAKING' })); return { configFile: configPath, affectedSections: ['seeders', 'mappings'], changes: configChanges, migrationNeeded: configChanges.some(c => c.required), backupRecommended: changes.some(c => c.dataBackupRequired), estimatedEffort: configChanges.length > 5 ? 'HIGH' : configChanges.length > 2 ? 'MEDIUM' : 'LOW' }; } /** * Group changes by type for easier processing */ groupChangesByType(changes) { const grouped = new Map(); for (const change of changes) { const key = change.type; if (!grouped.has(key)) { grouped.set(key, []); } grouped.get(key).push(change); } return grouped; } /** * Generate automatic migration suggestions */ generateAutomaticMigrations(groupedChanges) { const suggestions = []; // Handle added nullable columns automatically const addedColumns = groupedChanges.get('COLUMN_ADDED') || []; const nullableColumns = addedColumns.filter(c => c.impact === 'COMPATIBLE'); if (nullableColumns.length > 0) { suggestions.push({ id: 'auto-nullable-columns', title: 'Handle New Nullable Columns', description: 'Automatically update configurations for new nullable columns', type: 'AUTOMATIC', priority: 'LOW', affectedChanges: nullableColumns.map(c => c.id), steps: [{ order: 1, title: 'Update Configuration', description: 'Add new nullable columns to seeding configurations with optional mappings', type: 'CONFIG', estimated_minutes: 5 }], estimatedTime: 5, riskLevel: 'LOW', prerequisites: [], rollbackPlan: ['Remove added column mappings from configuration'] }); } return suggestions; } /** * Generate semi-automatic migration suggestions */ generateSemiAutomaticMigrations(groupedChanges, impacts) { const suggestions = []; // Handle removed tables const removedTables = groupedChanges.get('TABLE_REMOVED') || []; if (removedTables.length > 0) { suggestions.push({ id: 'semi-auto-removed-tables', title: 'Handle Removed Tables', description: 'Update configurations to remove references to deleted tables', type: 'SEMI_AUTOMATIC', priority: 'HIGH', affectedChanges: removedTables.map(c => c.id), steps: [ { order: 1, title: 'Backup Affected Configurations', description: 'Create backups of configuration files that reference removed tables', type: 'MANUAL', estimated_minutes: 10 }, { order: 2, title: 'Remove Table References', description: 'Automatically remove references to deleted tables from configurations', type: 'CONFIG', estimated_minutes: 5 }, { order: 3, title: 'Validate Configuration', description: 'Validate updated configurations', type: 'VALIDATION', validation: 'Run configuration validation to ensure no broken references remain', estimated_minutes: 5 } ], estimatedTime: 20, riskLevel: 'MEDIUM', prerequisites: ['Configuration backups'], rollbackPlan: ['Restore configuration backups', 'Re-add table references if needed'] }); } return suggestions; } /** * Generate manual migration suggestions */ generateManualMigrations(groupedChanges, impacts) { const suggestions = []; // Handle column type changes const modifiedColumns = groupedChanges.get('COLUMN_MODIFIED') || []; const typeChanges = modifiedColumns.filter(c => c.description.includes('type changed')); if (typeChanges.length > 0) { suggestions.push({ id: 'manual-column-types', title: 'Handle Column Type Changes', description: 'Manually review and update seeding data for changed column types', type: 'MANUAL', priority: 'HIGH', affectedChanges: typeChanges.map(c => c.id), steps: [ { order: 1, title: 'Review Type Changes', description: 'Review all column type changes and their impact on existing data', type: 'MANUAL', estimated_minutes: 30 }, { order: 2, title: 'Update Seed Data', description: 'Update seed data files to match new column types', type: 'CODE', estimated_minutes: 60 }, { order: 3, title: 'Test Data Conversion', description: 'Test that existing data can be converted to new types', type: 'VALIDATION', validation: 'Run test seeding with updated data to ensure compatibility', estimated_minutes: 15 } ], estimatedTime: 105, riskLevel: 'HIGH', prerequisites: ['Database backup', 'Test environment'], rollbackPlan: [ 'Restore database from backup if needed', 'Revert seed data changes', 'Contact database administrator for schema rollback' ] }); } return suggestions; } /** * Get all captured snapshots */ getSnapshots() { return Array.from(this.snapshots.values()); } /** * Get a specific snapshot by ID */ getSnapshot(snapshotId) { return this.snapshots.get(snapshotId); } /** * Clear all snapshots (useful for testing) */ clearSnapshots() { this.snapshots.clear(); } } exports.SchemaEvolutionEngine = SchemaEvolutionEngine; //# sourceMappingURL=schema-evolution.js.map