New platform/api/ service owns students, cohorts, curriculum, progress, scenario attempts, and student/admin sessions. No k3s, no --privileged, no terminal runtime — starts instantly, unlike the old identity service which booted an unused k3s cluster just to serve roster/progress APIs. Provisioner calls (student and admin) are consolidated here too, in provisioner-client.js, so it's the only service that talks to the Provisioner directly.
122 lines
5.4 KiB
JavaScript
122 lines
5.4 KiB
JavaScript
#!/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); });
|