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.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/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); });
|
||||
Reference in New Issue
Block a user