/**
* @module database/migrate
* @description One-time migration from sentri-db.json → SQLite.
*
* Called automatically on startup if the SQLite database is empty but the
* legacy JSON file exists. Safe to run multiple times — skips if data already
* exists in SQLite.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { getDatabase } from "./sqlite.js";
import * as projectRepo from "./repositories/projectRepo.js";
import * as testRepo from "./repositories/testRepo.js";
import * as runRepo from "./repositories/runRepo.js";
import * as activityRepo from "./repositories/activityRepo.js";
import * as healingRepo from "./repositories/healingRepo.js";
import * as userRepo from "./repositories/userRepo.js";
import * as counterRepo from "./repositories/counterRepo.js";
import { formatLogLine } from "../utils/logFormatter.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LEGACY_DB_PATH = path.join(__dirname, "..", "..", "data", "sentri-db.json");
/**
* Check if migration is needed and perform it.
* Skips if SQLite already has projects or if the legacy JSON file doesn't exist.
*/
export function migrateFromJsonIfNeeded() {
// Check if legacy file exists
if (!fs.existsSync(LEGACY_DB_PATH)) {
return;
}
// Check if SQLite already has data (any projects means migration already happened)
const db = getDatabase();
const count = db.prepare("SELECT COUNT(*) as cnt FROM projects").get().cnt;
if (count > 0) {
console.log(formatLogLine("info", null, "[migrate] SQLite already has data — skipping JSON migration"));
return;
}
console.log(formatLogLine("info", null, `[migrate] Found legacy ${LEGACY_DB_PATH} — migrating to SQLite…`));
let data;
try {
const raw = fs.readFileSync(LEGACY_DB_PATH, "utf-8");
data = JSON.parse(raw);
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Failed to read legacy DB: ${err.message}`));
return;
}
const txn = db.transaction(() => {
// ── Users ──────────────────────────────────────────────────────────────
let userCount = 0;
for (const user of Object.values(data.users || {})) {
try {
userRepo.create(user);
userCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping user ${user.id}: ${err.message}`));
}
}
// ── OAuth IDs ──────────────────────────────────────────────────────────
let oauthCount = 0;
for (const [key, userId] of Object.entries(data.oauthIds || {})) {
try {
userRepo.setOAuthLink(key, userId);
oauthCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping oauthId ${key}: ${err.message}`));
}
}
// ── Projects ───────────────────────────────────────────────────────────
let projCount = 0;
for (const project of Object.values(data.projects || {})) {
try {
projectRepo.create(project);
projCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping project ${project.id}: ${err.message}`));
}
}
// ── Tests ──────────────────────────────────────────────────────────────
let testCount = 0;
for (const test of Object.values(data.tests || {})) {
try {
testRepo.create(test);
testCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping test ${test.id}: ${err.message}`));
}
}
// ── Runs ───────────────────────────────────────────────────────────────
let runCount = 0;
for (const run of Object.values(data.runs || {})) {
try {
runRepo.create(run);
runCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping run ${run.id}: ${err.message}`));
}
}
// ── Activities ─────────────────────────────────────────────────────────
let actCount = 0;
for (const activity of Object.values(data.activities || {})) {
try {
activityRepo.create(activity);
actCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping activity ${activity.id}: ${err.message}`));
}
}
// ── Healing History ────────────────────────────────────────────────────
let healCount = 0;
for (const [key, entry] of Object.entries(data.healingHistory || {})) {
try {
healingRepo.set(key, entry);
healCount++;
} catch (err) {
console.warn(formatLogLine("warn", null, `[migrate] Skipping healing ${key}: ${err.message}`));
}
}
// ── Counters ───────────────────────────────────────────────────────────
// Scan existing IDs to set counters correctly
function maxNum(obj, prefix) {
let max = 0;
for (const key of Object.keys(obj || {})) {
if (key.startsWith(prefix)) {
const n = parseInt(key.slice(prefix.length), 10);
if (n > max) max = n;
}
}
return max;
}
counterRepo.set("test", maxNum(data.tests, "TC-"));
counterRepo.set("run", maxNum(data.runs, "RUN-"));
counterRepo.set("project", maxNum(data.projects, "PRJ-"));
counterRepo.set("activity", maxNum(data.activities, "ACT-"));
console.log(formatLogLine("info", null,
`[migrate] Done — ${userCount} users, ${oauthCount} OAuth links, ${projCount} projects, ` +
`${testCount} tests, ${runCount} runs, ${actCount} activities, ${healCount} healing entries`
));
});
txn();
// Rename legacy file so it's not re-imported
try {
fs.renameSync(LEGACY_DB_PATH, LEGACY_DB_PATH + ".migrated");
console.log(formatLogLine("info", null, `[migrate] Renamed ${LEGACY_DB_PATH} → .migrated`));
} catch {
console.warn(formatLogLine("warn", null, "[migrate] Could not rename legacy file"));
}
}