import { Command } from "commander"; import path from "path"; import fs from "fs"; import chalk from "chalk"; import { Database } from "bun:sqlite"; import * as sqliteVec from "sqlite-vec"; import { confirm, select } from "@inquirer/prompts"; import grabDBDir from "../utils/grab-db-dir"; import grabDirNames from "../data/grab-dir-names"; import grabSortedExports from "../utils/grab-sorted-exports"; import grabBackupData from "../utils/grab-backup-data"; import grabDBBackupFileName from "../utils/grab-db-backup-file-name"; import snapshotDb from "../utils/snapshot-db"; import trimBackups from "../utils/trim-backups"; import replaceDbFile from "../utils/replace-db-file"; import { isArchivePath, isSQLiteFile, isSqlPath, readExportArchive, } from "../utils/export-archive"; import { resolveDataFile } from "../utils/resolve-and-load-data-file"; import { AppData } from "../data/app-data"; function formatChoice(name, index) { const { backup_date } = grabBackupData({ backup_name: name.replace(/\.(tar\.gz|tgz|tar|zip)$/i, ""), }); const time = Number.isNaN(backup_date.getTime()) ? "unknown date" : `${backup_date.toDateString()} ${backup_date.getHours()}:${backup_date.getMinutes()}:${backup_date.getSeconds().toString().padStart(2, "0")}`; return `#${index + 1}: ${name} (${time})`; } /** * # Replace the live database with the database file at `src` * @description The current database is backed up first. */ function swapDatabase(src) { const config = global.CONFIG; const { backup_dir, db_file_path } = grabDBDir({ config }); const backup_path = path.join(backup_dir, grabDBBackupFileName({ config })); snapshotDb({ db: global.SQLITE_CLIENT, dst: backup_path }); trimBackups({ config }); console.log(chalk.dim(`Backed up current database → ${backup_path}`)); global.SQLITE_CLIENT.close(); replaceDbFile({ src, db_file_path }); } /** * # Build a database file from an SQL script */ function buildDbFromSql({ sql, dst }) { fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.rmSync(dst, { force: true }); const db = new Database(dst, { create: true }); try { sqliteVec.load(db); db.run(sql); } finally { db.close(); } } /** * # Write the schema file from an archive into the project */ function writeSchema({ schema, schemaFileName, }) { const config = global.CONFIG; const { db_dir } = grabDBDir({ config }); if (config.db_schema_file_name) { const schemaPath = path.join(db_dir, config.db_schema_file_name); if (path.extname(schemaPath) !== path.extname(schemaFileName)) { throw new Error(`Archive schema is \`${schemaFileName}\` but \`db_schema_file_name\` is \`${config.db_schema_file_name}\`. Use --sql-only, or change \`db_schema_file_name\`.`); } fs.writeFileSync(schemaPath, schema, "utf-8"); console.log(chalk.green(`Schema written → ${schemaPath}`)); return; } const existing = resolveDataFile(db_dir, AppData.DbSchemaFileName); const schemaPath = path.join(db_dir, schemaFileName); // Remove a differently-named schema so only one format remains if (existing && path.resolve(existing.path) !== path.resolve(schemaPath)) { fs.unlinkSync(existing.path); console.log(chalk.dim(`Removed previous schema file: ${existing.basename}`)); } fs.writeFileSync(schemaPath, schema, "utf-8"); console.log(chalk.green(`Schema written → ${schemaPath}`)); } export default function () { return new Command("import") .description("Import a SQLite database file, an SQL script, or a full export archive (database + schema)") .argument("[file]", "Path to a SQLite file, .sql file, or export archive (.tar.gz / .tar / .zip)") .option("--sql-only", "When importing an archive, restore the database only (skip writing schema)") .option("--schema-only", "When importing an archive, write schema only (skip database restore)") .option("-y, --yes", "Do not ask for confirmation before writing an executable (.ts/.js) schema from the archive") .action(async (fileArg, opts) => { console.log(`Importing database ...`); const config = global.CONFIG; const { db_dir, export_dir } = grabDBDir({ config }); const { BUN_SQLITE_TEMP_DIR } = grabDirNames(); const temp_db_path = path.join(BUN_SQLITE_TEMP_DIR, `import-${Date.now()}.db`); /** Remove the temp database file before exiting */ const exit = (code) => { fs.rmSync(temp_db_path, { force: true }); process.exit(code); }; try { let filePath = fileArg ? path.resolve(fileArg) : ""; if (!filePath) { const candidates = grabSortedExports({ config }).filter((b) => isArchivePath(b)); if (!candidates[0]) { console.error(`No export archives found in \`${export_dir}\`. Use \`export\`, or pass a file path.`); exit(1); } const selected = await select({ message: "Select an export to import:", choices: candidates.map((b, i) => ({ name: formatChoice(b, i), value: b, })), }); filePath = path.join(export_dir, selected); } if (!fs.existsSync(filePath)) { console.error(chalk.red(`File not found: ${filePath}`)); exit(1); } if (opts.sqlOnly && opts.schemaOnly) { console.error(chalk.red(`Cannot combine --sql-only and --schema-only.`)); exit(1); } if (!isArchivePath(filePath)) { if (opts.schemaOnly) { console.error(chalk.red(`--schema-only requires an export archive.`)); exit(1); } if (isSqlPath(filePath)) { buildDbFromSql({ sql: fs.readFileSync(filePath, "utf-8"), dst: temp_db_path, }); swapDatabase(temp_db_path); } else if (isSQLiteFile(new Uint8Array(fs.readFileSync(filePath)))) { swapDatabase(filePath); } else { console.error(chalk.red(`Unsupported file: ${filePath}. Use a SQLite database file, .sql, .tar.gz, .tar, or .zip.`)); exit(1); } console.log(`${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`); exit(0); } const { db, schema, schemaFileName } = await readExportArchive(filePath); /** * A .ts/.js schema is executed (require) on the next run. * Confirm before writing code from an archive into the project. */ const isExecutableSchema = /\.(ts|js)$/i.test(schemaFileName); if (!opts.sqlOnly && isExecutableSchema && !opts.yes) { if (!process.stdin.isTTY) { console.error(chalk.red(`Archive contains an executable schema (${schemaFileName}). Re-run with --yes to accept it, or --sql-only to skip it.`)); exit(1); } console.log(chalk.yellow(`\nThis archive contains an executable schema file (${schemaFileName}).\nIt will run as code the next time bun-sqlite or your app loads it.\nOnly continue if you trust the source of this archive.\n`)); const proceed = await confirm({ message: `Write ${schemaFileName} into ${db_dir}?`, default: false, }); if (!proceed) { console.log(chalk.dim(`Import cancelled.`)); exit(1); } } if (!opts.sqlOnly) { writeSchema({ schema, schemaFileName }); } if (!opts.schemaOnly) { fs.mkdirSync(path.dirname(temp_db_path), { recursive: true, }); fs.writeFileSync(temp_db_path, db); swapDatabase(temp_db_path); console.log(chalk.green(`Database restored from archive`)); } console.log(`${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`); exit(0); } catch (error) { console.error(chalk.red(`Import ERROR => ${error.message}`)); exit(1); } }); }