#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { DatabaseSync } from "node:sqlite"; const root = path.resolve(import.meta.dirname, ".."); const databasePath = path.join(root, "var", "recipe-book.sqlite"); const defaultSeedPath = path.join(root, "data", "recipe-book-backup.json"); // Dynamically import compiled or source backup engine import { exportDatabase } from "../src/lib/backup/export-database.ts"; import { importDatabase } from "../src/lib/backup/import-database.ts"; import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts"; function printUsage() { console.log(` Formulation Database Backup & Restore Tool Usage: node scripts/backup.mjs export [output-path.json] node scripts/backup.mjs import [input-path.json] [--replace | --merge] node scripts/backup.mjs validate Commands: export Extracts all 25 SQLite tables into a standardized JSON backup bundle. import Restores or merges a backup bundle into the active SQLite database (defaults to data/recipe-book-backup.json). validate Checks a backup JSON file for schema integrity without modifying the database. Options: --replace (Default for import) Atomically replaces existing database records. --merge Upserts imported records without deleting unmentioned existing data. `); } function ensureDatabaseSchema(db) { const migrationsDir = path.join(root, "migrations"); if (fs.existsSync(migrationsDir)) { const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort(); for (const file of files) { try { const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8"); db.exec(sql); } catch { // Schema migrations may already be partially or fully applied } } } } async function main() { const args = process.argv.slice(2); const command = args[0]?.toLowerCase(); if (!command || command === "--help" || command === "-h" || command === "help") { printUsage(); process.exit(0); } if (command === "export") { if (!fs.existsSync(databasePath)) { console.error(`Error: Database not found at ${databasePath}`); process.exit(1); } const db = new DatabaseSync(databasePath); try { const bundle = exportDatabase(db); const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`; const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8"); console.log(`\n✅ Backup exported successfully to: ${outputPath}`); console.log(` - Recipes: ${bundle.summary.recipes_count}`); console.log(` - Ingredients: ${bundle.summary.ingredients_count}`); console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`); console.log(` - Collections: ${bundle.summary.collections_count}`); console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`); console.log(` - Total Entities: ${bundle.summary.total_records_count}`); } finally { db.close(); } return; } if (command === "validate") { const inputPath = args[1] || defaultSeedPath; const resolvedPath = path.resolve(process.cwd(), inputPath); if (!fs.existsSync(resolvedPath)) { console.error(`Error: File not found: ${resolvedPath}`); process.exit(1); } const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); const result = validateBackupBundle(content); if (result.valid) { console.log(`\n✅ Backup file '${inputPath}' is valid!`); if (result.summary) { console.log(` - Format Version: ${content.format_version}`); console.log(` - Exported At: ${content.exported_at}`); console.log(` - Recipes: ${result.summary.recipes_count}`); console.log(` - Ingredients: ${result.summary.ingredients_count}`); console.log(` - Purchase Items: ${result.summary.purchase_items_count}`); console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`); console.log(` - Total Entities: ${result.summary.total_records_count}`); } } else { console.error(`\n❌ Backup validation failed:`); for (const err of result.errors) console.error(` - ${err}`); process.exit(1); } return; } if (command === "import") { const fileArg = args.find((arg, idx) => idx > 0 && !arg.startsWith("--")); const inputPath = fileArg || (fs.existsSync(defaultSeedPath) ? defaultSeedPath : null); if (!inputPath) { console.error("Error: Please specify the path to a backup JSON file to import."); process.exit(1); } const resolvedPath = path.resolve(process.cwd(), inputPath); if (!fs.existsSync(resolvedPath)) { console.error(`Error: File not found: ${resolvedPath}`); process.exit(1); } const mode = args.includes("--merge") ? "merge" : "replace"; const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); fs.mkdirSync(path.dirname(databasePath), { recursive: true }); const db = new DatabaseSync(databasePath); ensureDatabaseSchema(db); try { console.log(`Importing '${inputPath}' into database (mode: ${mode})...`); const result = importDatabase(db, content, { mode, rebuildProjections: true }); console.log(`\n✅ ${result.message}`); } catch (err) { console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } finally { db.close(); } return; } console.error(`Error: Unknown command '${command}'`); printUsage(); process.exit(1); } main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });