188 lines
8.5 KiB
JavaScript
188 lines
8.5 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|