name: M-AI-02 Backward Compatibility on: push: branches: [main] jobs: backward-compat: runs-on: prod steps: - name: Checkout M-AI-02 (latest) run: | if [ -d /tmp/api-server-compat ]; then cd /tmp/api-server-compat && git fetch origin && git reset --hard origin/main else git clone http://10.2.0.7:3000/wangdl/api-server.git /tmp/api-server-compat fi - name: Ensure infrastructure run: | docker start mysql redis 2>/dev/null || true sleep 2 - name: Apply M-AI-02 migrations run: | cd /tmp/api-server-compat npm ci DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate deploy timeout-minutes: 5 - name: Checkout M-AI-01 baseline run: | mkdir -p /tmp/api-server-old cd /tmp/api-server-old if [ -d .git ]; then git fetch origin && git checkout 789d6ec --force else git clone http://10.2.0.7:3000/wangdl/api-server.git . git checkout 789d6ec fi - name: Install M-AI-01 dependencies run: | cd /tmp/api-server-old npm ci timeout-minutes: 5 - name: Generate M-AI-01 Prisma client run: | cd /tmp/api-server-old npx prisma generate - name: Run backward compatibility script run: | cd /tmp/api-server-old cat > /tmp/compat-test.ts << 'TESTEOF' import { PrismaClient } from '@prisma/client'; const DB_URL = process.env.DATABASE_URL || ''; // Use direct MySQL for old-model access const prisma = new PrismaClient({ datasources: { db: { url: DB_URL } } }); async function main() { const TEST_USER = 'backward-compat-test-' + Date.now(); let errors = 0; // 1. Create test user await prisma.$executeRawUnsafe( "INSERT IGNORE INTO User (id, email, createdAt, updatedAt) VALUES (?, ?, NOW(), NOW())", TEST_USER, TEST_USER + '@test.com' ); console.log('1. User created: ' + TEST_USER); // 2. createJob via old Prisma delegate (prisma.aiAnalysisJob.create) let job: any; try { job = await (prisma as any).aiAnalysisJob.create({ data: { userId: TEST_USER, jobType: 'feynman-evaluation', status: 'pending', sessionId: null, answerId: null, queuedAt: new Date(), }, }); console.log('2. createJob: id=' + job.id + ' status=' + job.status + ' ✅'); } catch (e: any) { console.log('2. createJob FAILED: ' + e.message + ' ❌'); errors++; await prisma.$disconnect(); process.exit(1); } // 3. findById / findUnique try { const found = await (prisma as any).aiAnalysisJob.findUnique({ where: { id: job.id }, include: { results: true }, }); if (!found) throw new Error('not found'); console.log('3. findById: found id=' + found.id + ' status=' + found.status + ' ✅'); } catch (e: any) { console.log('3. findById FAILED: ' + e.message + ' ❌'); errors++; } // 4. pending → processing (updateJobStatus) try { const updated = await (prisma as any).aiAnalysisJob.update({ where: { id: job.id }, data: { status: 'processing', startedAt: new Date() }, }); console.log('4. pending→processing: status=' + updated.status + ' ✅'); } catch (e: any) { console.log('4. pending→processing FAILED: ' + e.message + ' ❌'); errors++; } // 5. processing → completed try { const updated = await (prisma as any).aiAnalysisJob.update({ where: { id: job.id }, data: { status: 'completed', completedAt: new Date() }, }); console.log('5. processing→completed: status=' + updated.status + ' ✅'); } catch (e: any) { console.log('5. processing→completed FAILED: ' + e.message + ' ❌'); errors++; } // 6. Create second job for failed test let job2: any; try { job2 = await (prisma as any).aiAnalysisJob.create({ data: { userId: TEST_USER, jobType: 'active-recall', status: 'pending', queuedAt: new Date(), }, }); console.log('6. Second job created: id=' + job2.id + ' ✅'); } catch (e: any) { console.log('6. Second job FAILED: ' + e.message + ' ❌'); errors++; } // 7. processing → failed with errorMessage if (job2) { try { const updated = await (prisma as any).aiAnalysisJob.update({ where: { id: job2.id }, data: { status: 'processing', startedAt: new Date() }, }); const failed = await (prisma as any).aiAnalysisJob.update({ where: { id: job2.id }, data: { status: 'failed', errorMessage: 'test error', completedAt: new Date() }, }); console.log('7. processing→failed: status=' + failed.status + ' errorMessage=' + failed.errorMessage + ' ✅'); } catch (e: any) { console.log('7. processing→failed FAILED: ' + e.message + ' ❌'); errors++; } } // 8. Verify physical columns preserved const cols = await prisma.$queryRawUnsafe( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'AiAnalysisJob' AND COLUMN_NAME IN ('errorMessage','completedAt','status','jobType') ORDER BY ORDINAL_POSITION" ) as any[]; const colNames = cols.map(c => c.COLUMN_NAME); const required = ['jobType','status','errorMessage','completedAt']; const missing = required.filter(r => !colNames.includes(r)); if (missing.length === 0) { console.log('8. Physical columns preserved: ' + colNames.join(', ') + ' ✅'); } else { console.log('8. MISSING columns: ' + missing.join(', ') + ' ❌'); errors++; } // 9. AiAnalysisResult FK try { await (prisma as any).aiAnalysisResult.create({ data: { userId: TEST_USER, jobId: job.id, summary: 'test summary', masteryScore: 80, }, }); const results = await (prisma as any).aiAnalysisResult.findMany({ where: { jobId: job.id }, }); console.log('9. AiAnalysisResult FK: ' + results.length + ' results for job ✅'); } catch (e: any) { console.log('9. AiAnalysisResult FK FAILED: ' + e.message + ' ❌'); errors++; } // 10. AiUsageLog insert try { await (prisma as any).aiUsageLog.create({ data: { userId: TEST_USER, feature: 'compat-test', provider: 'deepseek', model: 'deepseek-chat', tier: 'primary', promptKey: 'test', promptVersion: 'v1', }, }); console.log('10. AiUsageLog insert ✅'); } catch (e: any) { console.log('10. AiUsageLog insert FAILED: ' + e.message + ' ❌'); errors++; } // Cleanup await prisma.$executeRawUnsafe("DELETE FROM AiAnalysisResult WHERE userId = ?", TEST_USER); await prisma.$executeRawUnsafe("DELETE FROM AiJobArtifact WHERE jobId IN (SELECT id FROM AiAnalysisJob WHERE userId = ?)", TEST_USER); await (prisma as any).aiUsageLog.deleteMany({ where: { userId: TEST_USER } }); await (prisma as any).aiAnalysisJob.deleteMany({ where: { userId: TEST_USER } }); await prisma.$executeRawUnsafe("DELETE FROM User WHERE id = ?", TEST_USER); console.log('Cleanup done.'); await prisma.$disconnect(); if (errors > 0) { console.log('\n' + errors + ' test(s) FAILED ❌'); process.exit(1); } console.log('\nAll backward compatibility tests PASSED ✅'); } main().catch(e => { console.error(e); process.exit(1); }); TESTEOF DATABASE_URL="${{ secrets.DATABASE_URL }}" npx tsx /tmp/compat-test.ts timeout-minutes: 5 env: DATABASE_URL: ${{ secrets.DATABASE_URL }}