#!/usr/bin/env node // SQLite -> Postgres migration — the one-time cutover for Phase 2B. Run once against the real // data volume before treating Postgres as authoritative. See // docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Migration process / rollback" for the full // procedure and how to roll back. // // Usage: // SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js // // Guarantees: // - Backs up the SQLite file before touching anything (never modifies or deletes the source). // - Idempotent: safe to re-run. Every write uses ON CONFLICT — re-running re-syncs from the // SQLite source, it does not duplicate rows or fail on a second run. // - Never silently drops data: prints (and writes to a report file) exactly what was read, // what was written, and anything that couldn't be migrated and why. // - Preserves the old students.cohort column as a real cohort + enrollment, not just a // dropped string. const fs = require('fs'); const path = require('path'); const Database = require('better-sqlite3'); const { getPool, runMigrations } = require('./src/db'); const SQLITE_PATH = process.env.SQLITE_PATH || '/data/progress.db'; async function main() { const report = { students: 0, cohorts_created: 0, enrollments: 0, progress_rows: 0, exam_sessions: 0, skipped: [] }; if (!fs.existsSync(SQLITE_PATH)) { console.error(`SQLite file not found at ${SQLITE_PATH} — nothing to migrate. Set SQLITE_PATH if it's elsewhere.`); process.exit(1); } // 1. Backup — never touch the only copy of pre-migration data. const backupPath = `${SQLITE_PATH}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`; fs.copyFileSync(SQLITE_PATH, backupPath); console.log(`Backed up ${SQLITE_PATH} -> ${backupPath}`); const sqlite = new Database(SQLITE_PATH, { readonly: true }); await runMigrations(); const pool = getPool(); // 2. Students + cohorts + enrollments const studentRows = safeAll(sqlite, `SELECT * FROM students`, report, 'students'); const cohortsSeen = new Set(); for (const s of studentRows) { await pool.query( `INSERT INTO students (student_id, display_name, created_at, last_active_at) VALUES ($1, $2, $3, $4) ON CONFLICT (student_id) DO UPDATE SET display_name = excluded.display_name, last_active_at = excluded.last_active_at`, [s.student_id, s.display_name, s.created_at || new Date().toISOString(), s.last_active_at || null] ); report.students++; if (s.cohort && !cohortsSeen.has(s.cohort)) { cohortsSeen.add(s.cohort); const { rowCount } = await pool.query( `INSERT INTO cohorts (cohort_id, name) VALUES ($1, $1) ON CONFLICT (cohort_id) DO NOTHING`, [s.cohort] ); if (rowCount) report.cohorts_created++; } if (s.cohort) { await pool.query( `INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [s.cohort, s.student_id] ); report.enrollments++; } } // 3. Progress const progressRows = safeAll(sqlite, `SELECT * FROM progress`, report, 'progress'); for (const p of progressRows) { await pool.query( `INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (student_id, scenario_id) DO UPDATE SET status = excluded.status, attempts = excluded.attempts, last_validated = excluded.last_validated, completed_at = excluded.completed_at, started_at = excluded.started_at, notes = excluded.notes, time_spent_seconds = excluded.time_spent_seconds`, [p.student_id, p.scenario_id, p.status, p.attempts || 0, p.last_validated, p.completed_at, p.started_at, p.notes, p.time_spent_seconds || 0] ); report.progress_rows++; } // 4. Exam sessions (historical import only — see migrations/006_exam_sessions.sql note) const sessionRows = safeAll(sqlite, `SELECT * FROM sessions`, report, 'sessions'); for (const s of sessionRows) { await pool.query( `INSERT INTO exam_sessions (session_id, student_id, bundle_id, started_at, submitted_at, status, exam_minutes, duration_secs, snapshot, scenario_ids) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (session_id) DO UPDATE SET status = excluded.status, submitted_at = excluded.submitted_at`, [s.id, s.student_id || null, s.bundle_id, s.started_at, s.submitted_at, s.status, s.exam_minutes, s.duration_secs, s.snapshot ? JSON.parse(s.snapshot) : null, s.scenario_ids ? JSON.parse(s.scenario_ids) : null] ); report.exam_sessions++; } sqlite.close(); const reportPath = path.join(path.dirname(SQLITE_PATH), `migration-report-${Date.now()}.json`); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); console.log('\nMigration report:'); console.log(JSON.stringify(report, null, 2)); console.log(`\nFull report written to ${reportPath}`); console.log(`Backup preserved at ${backupPath} — not deleted, not modified.`); await pool.end(); } function safeAll(sqlite, sql, report, label) { try { return sqlite.prepare(sql).all(); } catch (e) { report.skipped.push({ table: label, reason: e.message }); console.warn(`Could not read ${label}: ${e.message} (table may not exist in this SQLite file — recorded in report, not fatal)`); return []; } } main().catch((e) => { console.error('Migration failed:', e); process.exit(1); });