Files
ced4568 6b52c9e1d2 feat(platform-api): split lightweight platform service from Lab image
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.
2026-08-18 12:22:58 -05:00

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); });