#!/usr/bin/env tsx /** * Schema Validation Script * * Pre-migration validation script that checks schema consistency * across the submission pipeline before deploying changes. * * Usage: * npm run validate-schema * or * tsx scripts/validate-schema.ts * * Exit codes: * 0 = All validations passed * 1 = Validation failures detected */ import { createClient } from '@supabase/supabase-js'; const SUPABASE_URL = 'https://ydvtmnrszybqnbcqbdcy.supabase.co'; const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!SUPABASE_KEY) { console.error('❌ SUPABASE_SERVICE_ROLE_KEY environment variable is required'); process.exit(1); } const supabase = createClient(SUPABASE_URL, SUPABASE_KEY); interface ValidationResult { category: string; test: string; passed: boolean; message?: string; } const results: ValidationResult[] = []; async function getTableColumns(tableName: string): Promise> { const { data, error } = await supabase .from('information_schema.columns' as any) .select('column_name') .eq('table_schema', 'public') .eq('table_name', tableName); if (error) throw error; return new Set(data?.map((row: any) => row.column_name) || []); } async function validateSubmissionTable( mainTable: string, submissionTable: string, entityName: string ): Promise { const mainColumns = await getTableColumns(mainTable); const submissionColumns = await getTableColumns(submissionTable); const excludedFields = new Set([ 'id', 'created_at', 'updated_at', 'is_test_data', 'view_count_all', 'view_count_30d', 'view_count_7d', 'average_rating', 'review_count', 'installations_count', ]); const missingFields: string[] = []; for (const field of mainColumns) { if (excludedFields.has(field)) continue; if (!submissionColumns.has(field)) { missingFields.push(field); } } if (missingFields.length === 0) { results.push({ category: 'Submission Tables', test: `${entityName}: submission table matches main table`, passed: true, }); } else { results.push({ category: 'Submission Tables', test: `${entityName}: submission table matches main table`, passed: false, message: `Missing fields: ${missingFields.join(', ')}`, }); } } async function validateVersionTable( mainTable: string, versionTable: string, entityName: string ): Promise { const mainColumns = await getTableColumns(mainTable); const versionColumns = await getTableColumns(versionTable); const excludedFields = new Set([ 'id', 'created_at', 'updated_at', 'is_test_data', 'view_count_all', 'view_count_30d', 'view_count_7d', 'average_rating', 'review_count', 'installations_count', ]); const fieldMapping: { [key: string]: string } = { 'height_requirement': 'height_requirement_cm', 'max_g_force': 'gforce_max', 'inversions': 'inversions_count', 'max_height_meters': 'height_meters', 'drop_height_meters': 'drop_meters', }; const requiredVersionFields = new Set([ 'version_id', 'version_number', 'change_type', 'change_reason', 'is_current', 'created_by', 'submission_id', 'is_test_data', ]); const missingMainFields: string[] = []; const missingVersionFields: string[] = []; // Check main table fields exist in version table for (const field of mainColumns) { if (excludedFields.has(field)) continue; const mappedField = fieldMapping[field] || field; if (!versionColumns.has(field) && !versionColumns.has(mappedField)) { missingMainFields.push(field); } } // Check version metadata fields exist for (const field of requiredVersionFields) { if (!versionColumns.has(field)) { missingVersionFields.push(field); } } if (missingMainFields.length === 0 && missingVersionFields.length === 0) { results.push({ category: 'Version Tables', test: `${entityName}: version table has all fields`, passed: true, }); } else { const messages: string[] = []; if (missingMainFields.length > 0) { messages.push(`Missing main fields: ${missingMainFields.join(', ')}`); } if (missingVersionFields.length > 0) { messages.push(`Missing version fields: ${missingVersionFields.join(', ')}`); } results.push({ category: 'Version Tables', test: `${entityName}: version table has all fields`, passed: false, message: messages.join('; '), }); } } async function validateCriticalFields(): Promise { const ridesColumns = await getTableColumns('rides'); const rideModelsColumns = await getTableColumns('ride_models'); // Rides should NOT have ride_type if (!ridesColumns.has('ride_type')) { results.push({ category: 'Critical Fields', test: 'rides table does NOT have ride_type column', passed: true, }); } else { results.push({ category: 'Critical Fields', test: 'rides table does NOT have ride_type column', passed: false, message: 'rides table incorrectly has ride_type column', }); } // Rides MUST have category if (ridesColumns.has('category')) { results.push({ category: 'Critical Fields', test: 'rides table has category column', passed: true, }); } else { results.push({ category: 'Critical Fields', test: 'rides table has category column', passed: false, message: 'rides table is missing required category column', }); } // Ride models must have both category and ride_type if (rideModelsColumns.has('category') && rideModelsColumns.has('ride_type')) { results.push({ category: 'Critical Fields', test: 'ride_models has both category and ride_type', passed: true, }); } else { const missing: string[] = []; if (!rideModelsColumns.has('category')) missing.push('category'); if (!rideModelsColumns.has('ride_type')) missing.push('ride_type'); results.push({ category: 'Critical Fields', test: 'ride_models has both category and ride_type', passed: false, message: `ride_models is missing: ${missing.join(', ')}`, }); } } async function validateFunctions(): Promise { const functionsToCheck = [ 'create_entity_from_submission', 'update_entity_from_submission', 'process_approval_transaction', ]; for (const funcName of functionsToCheck) { try { const { data, error } = await supabase .rpc('pg_catalog.pg_function_is_visible' as any, { funcid: `public.${funcName}`::any } as any); if (!error) { results.push({ category: 'Functions', test: `${funcName} exists and is accessible`, passed: true, }); } else { results.push({ category: 'Functions', test: `${funcName} exists and is accessible`, passed: false, message: error.message, }); } } catch (err) { results.push({ category: 'Functions', test: `${funcName} exists and is accessible`, passed: false, message: err instanceof Error ? err.message : String(err), }); } } } function printResults(): void { console.log('\n' + '='.repeat(80)); console.log('Schema Validation Results'); console.log('='.repeat(80) + '\n'); const categories = [...new Set(results.map(r => r.category))]; let totalPassed = 0; let totalFailed = 0; for (const category of categories) { const categoryResults = results.filter(r => r.category === category); const passed = categoryResults.filter(r => r.passed).length; const failed = categoryResults.filter(r => !r.passed).length; console.log(`\n${category}:`); console.log('-'.repeat(80)); for (const result of categoryResults) { const icon = result.passed ? '✅' : '❌'; console.log(`${icon} ${result.test}`); if (result.message) { console.log(` └─ ${result.message}`); } } totalPassed += passed; totalFailed += failed; } console.log('\n' + '='.repeat(80)); console.log(`Total: ${totalPassed} passed, ${totalFailed} failed`); console.log('='.repeat(80) + '\n'); } async function main(): Promise { console.log('🔍 Starting schema validation...\n'); try { // Validate submission tables await validateSubmissionTable('parks', 'park_submissions', 'Parks'); await validateSubmissionTable('rides', 'ride_submissions', 'Rides'); await validateSubmissionTable('companies', 'company_submissions', 'Companies'); await validateSubmissionTable('ride_models', 'ride_model_submissions', 'Ride Models'); // Validate version tables await validateVersionTable('parks', 'park_versions', 'Parks'); await validateVersionTable('rides', 'ride_versions', 'Rides'); await validateVersionTable('companies', 'company_versions', 'Companies'); await validateVersionTable('ride_models', 'ride_model_versions', 'Ride Models'); // Validate critical fields await validateCriticalFields(); // Validate functions await validateFunctions(); // Print results printResults(); // Exit with appropriate code const hasFailures = results.some(r => !r.passed); if (hasFailures) { console.error('❌ Schema validation failed. Please fix the issues above before deploying.\n'); process.exit(1); } else { console.log('✅ All schema validations passed. Safe to deploy.\n'); process.exit(0); } } catch (error) { console.error('❌ Fatal error during validation:'); console.error(error); process.exit(1); } } main();