diff --git a/README.md b/README.md index 5484b66..ac0a1a8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ A schema-driven MariaDB manager for [Bun](https://bun.sh), featuring automatic s - [`typedef`](#typedef--generate-typescript-types-only) - [`backup`](#backup--sql-dump-backup) - [`restore`](#restore--restore-from-an-sql-dump) + - [`export`](#export--portable-sql--schema-archive) + - [`import`](#import--sql-or-full-export-archive) - [`admin`](#admin--interactive-admin-tools) - [CRUD API](#crud-api) - [Select](#select) @@ -191,6 +193,7 @@ The config file must be named `bun-mariadb.config.ts` and placed at the project | `db_dir` | `string` | Yes | Directory for schema, types, and local artifacts (relative to project root) | | `db_backup_dir` | `string` | No | Backup directory name, relative to `db_dir` (default: `.backups`) | | `max_backups` | `number` | No | Max backup files to keep (default: `10`) | +| `max_exports` | `number` | No | Max export archives to keep (default: `10`) | | `typedef_file_path` | `string` | No | Output path for generated TypeScript types (relative to project root) | | `db_config` | `object` | No | Extra options passed to Bun's `SQL` MariaDB adapter | | `charset` | `string` | No | Database charset (default: `utf8mb4`) | @@ -359,6 +362,75 @@ Interactive picker of available `.sql` backups (newest first). Imports the selec Requires `mariadb` or `mysql` on `PATH`. +### `export` — Portable SQL + schema archive + +```bash +bunx bun-mariadb export [options] +``` + +| Option | Description | +| ------------------- | --------------------------------------------------------------------------- | +| `-o`, `--output` | Output archive path (`.tar.gz` or `.zip`). Defaults to `{db_dir}/.exports` | +| `-f`, `--format` | When `--output` is omitted: `tar.gz` (default) or `zip` | + +Creates a portable archive containing: + +1. `dump.sql` — full SQL dump (same options as `backup`) +2. `schema.ts` — the project’s TypeScript schema file from `db_dir` + +Archives are written to `{db_dir}/.exports` (sibling of the backups directory). Old exports are pruned using `max_exports`. + +**Examples:** + +```bash +# Write {db_name}-{timestamp}.tar.gz into {db_dir}/.exports +bunx bun-mariadb export + +# Explicit path / format +bunx bun-mariadb export -o ./my-app-export.tar.gz +bunx bun-mariadb export -f zip +bunx bun-mariadb export -o ./my-app-export.zip +``` + +Requires `mariadb-dump` or `mysqldump` on `PATH`. Zip output also requires `zip`. + +### `import` — SQL or full export archive + +```bash +bunx bun-mariadb import [file] [options] +``` + +| Option | Description | +| ---------------- | --------------------------------------------------------------------------- | +| `[file]` | Path to a `.sql` dump or export archive (`.tar.gz` / `.tar` / `.zip`) | +| `--sql-only` | Archive only: restore SQL, do not overwrite `schema.ts` | +| `--schema-only` | Archive only: write `schema.ts`, do not restore SQL | + +Behavior: + +- **`.sql` file** — restores SQL into the configured database (same as restore, but non-interactive) +- **Export archive** — restores `dump.sql` and writes `schema.ts` into `db_dir` +- **No file argument** — interactive picker of archives in `{db_dir}/.exports` + +**Examples:** + +```bash +# Plain SQL dump +bunx bun-mariadb import ./db/.backups/my_app-1710000000000.sql + +# Full export (SQL + schema.ts) +bunx bun-mariadb import ./db/exports/my_app-1710000000000.tar.gz + +# Archive: SQL only / schema only +bunx bun-mariadb import ./export.tar.gz --sql-only +bunx bun-mariadb import ./export.tar.gz --schema-only + +# Interactive picker (from {db_dir}/.exports) +bunx bun-mariadb import +``` + +Requires `mariadb` or `mysql` on `PATH`. Zip archives also require `unzip`. + ### `admin` — Interactive admin tools ```bash diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 243e6af..cba11a3 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -5,30 +5,7 @@ import grabDBDir from "../utils/grab-db-dir"; import grabDBBackupFileName from "../utils/grab-db-backup-file-name"; import chalk from "chalk"; import trimBackups from "../utils/trim-backups"; -import mariadbCliEnv, { - mariadbCliConnectionArgs, -} from "../utils/mariadb-cli-env"; - -/** - * Prefer mariadb-dump, fall back to mysqldump. - */ -function resolveDumpBinary(): string { - const candidates = ["mariadb-dump", "mysqldump"]; - for (const bin of candidates) { - try { - const result = Bun.spawnSync(["which", bin], { - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode === 0) { - return new TextDecoder().decode(result.stdout).trim() || bin; - } - } catch { - // try next - } - } - return "mariadb-dump"; -} +import { dumpDatabase } from "../utils/mariadb-dump-restore"; export default function () { return new Command("backup") @@ -46,40 +23,9 @@ export default function () { const backup_file_name = grabDBBackupFileName({ config }); const backup_path = path.join(backup_dir, backup_file_name); - const dumpBin = resolveDumpBinary(); - const args = [ - dumpBin, - ...mariadbCliConnectionArgs(), - "--single-transaction", - "--routines", - "--triggers", - "--events", - config.db_name, - ]; - try { - const proc = Bun.spawn(args, { - stdout: "pipe", - stderr: "pipe", - env: mariadbCliEnv(), - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - if (exitCode !== 0) { - console.error( - chalk.red( - `Backup failed (exit ${exitCode}): ${stderr || "unknown error"}`, - ), - ); - process.exit(1); - } - - fs.writeFileSync(backup_path, stdout, "utf-8"); + const sql = await dumpDatabase(config); + fs.writeFileSync(backup_path, sql, "utf-8"); trimBackups({ config }); console.log( @@ -87,11 +33,7 @@ export default function () { ); process.exit(0); } catch (error: any) { - console.error( - chalk.red( - `Backup ERROR => ${error.message}. Ensure \`mariadb-dump\` or \`mysqldump\` is installed.`, - ), - ); + console.error(chalk.red(`Backup ERROR => ${error.message}`)); process.exit(1); } }); diff --git a/src/commands/export.ts b/src/commands/export.ts new file mode 100644 index 0000000..fd861f2 --- /dev/null +++ b/src/commands/export.ts @@ -0,0 +1,95 @@ +import { Command } from "commander"; +import path from "path"; +import fs from "fs"; +import chalk from "chalk"; +import grabDBDir from "../utils/grab-db-dir"; +import { AppData } from "../data/app-data"; +import { dumpDatabase } from "../utils/mariadb-dump-restore"; +import { writeExportArchive } from "../utils/export-archive"; +import trimExports from "../utils/trim-exports"; + +function defaultExportFileName( + dbName: string, + format: "tar.gz" | "zip", +): string { + return `${dbName}-${Date.now()}.${format}`; +} + +export default function () { + return new Command("export") + .description( + "Export database SQL dump + schema.ts into a portable archive", + ) + .option( + "-o, --output ", + "Output archive path (.tar.gz or .zip). Defaults to db exports dir", + ) + .option( + "-f, --format ", + "Archive format when --output is omitted: tar.gz | zip", + "tar.gz", + ) + .action(async (opts) => { + console.log(`Exporting database ...`); + + const config = global.CONFIG; + const { db_dir, export_dir } = grabDBDir({ config }); + + if (!fs.existsSync(export_dir)) { + fs.mkdirSync(export_dir, { recursive: true }); + } + + const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); + if (!fs.existsSync(schemaPath)) { + console.error( + chalk.red(`Schema file not found: ${schemaPath}`), + ); + process.exit(1); + } + + const formatRaw = String(opts.format || "tar.gz").toLowerCase(); + const format: "tar.gz" | "zip" = + formatRaw === "zip" ? "zip" : "tar.gz"; + + let outPath: string; + if (opts.output) { + outPath = path.resolve(opts.output); + const parent = path.dirname(outPath); + if (!fs.existsSync(parent)) { + fs.mkdirSync(parent, { recursive: true }); + } + } else { + outPath = path.join( + export_dir, + defaultExportFileName(config.db_name, format), + ); + } + + try { + const sql = await dumpDatabase(config); + const schemaTs = fs.readFileSync(schemaPath, "utf-8"); + + await writeExportArchive({ + contents: { sql, schemaTs }, + outPath, + }); + + if (path.dirname(outPath) === export_dir) { + trimExports({ config }); + } + + console.log( + `${chalk.bold(chalk.green(`DB Export Success!`))} → ${outPath}`, + ); + console.log( + chalk.dim( + `Contains: dump.sql + ${AppData.DbSchemaFileName}`, + ), + ); + process.exit(0); + } catch (error: any) { + console.error(chalk.red(`Export ERROR => ${error.message}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/import.ts b/src/commands/import.ts new file mode 100644 index 0000000..8f056c9 --- /dev/null +++ b/src/commands/import.ts @@ -0,0 +1,143 @@ +import { Command } from "commander"; +import path from "path"; +import fs from "fs"; +import chalk from "chalk"; +import { select } from "@inquirer/prompts"; +import grabDBDir from "../utils/grab-db-dir"; +import grabSortedExports from "../utils/grab-sorted-exports"; +import grabBackupData from "../utils/grab-backup-data"; +import { AppData } from "../data/app-data"; +import { restoreDatabase } from "../utils/mariadb-dump-restore"; +import { + isArchivePath, + isSqlPath, + readExportArchive, +} from "../utils/export-archive"; + +function formatChoice(name: string, index: number): string { + const { backup_date } = grabBackupData({ backup_name: name }); + 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})`; +} + +export default function () { + return new Command("import") + .description( + "Import an SQL dump, or a full export archive (SQL + schema.ts)", + ) + .argument( + "[file]", + "Path to .sql file or export archive (.tar.gz / .tar / .zip)", + ) + .option( + "--sql-only", + "When importing an archive, restore SQL only (skip writing schema.ts)", + ) + .option( + "--schema-only", + "When importing an archive, write schema.ts only (skip SQL restore)", + ) + .action(async (fileArg: string | undefined, opts) => { + console.log(`Importing database ...`); + + const config = global.CONFIG; + const { db_dir, export_dir } = grabDBDir({ config }); + + 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.`, + ); + process.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}`)); + process.exit(1); + } + + if (isSqlPath(filePath)) { + if (opts.schemaOnly) { + console.error( + chalk.red( + `--schema-only requires an export archive, not a plain .sql file.`, + ), + ); + process.exit(1); + } + + const sql = fs.readFileSync(filePath, "utf-8"); + await restoreDatabase(config, sql); + + console.log( + `${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`, + ); + process.exit(0); + } + + if (!isArchivePath(filePath)) { + console.error( + chalk.red( + `Unsupported file type: ${filePath}. Use .sql, .tar.gz, .tar, or .zip.`, + ), + ); + process.exit(1); + } + + if (opts.sqlOnly && opts.schemaOnly) { + console.error( + chalk.red( + `Cannot combine --sql-only and --schema-only.`, + ), + ); + process.exit(1); + } + + const { sql, schemaTs } = await readExportArchive(filePath); + + if (!opts.schemaOnly) { + await restoreDatabase(config, sql); + console.log(chalk.green(`SQL restored from archive`)); + } + + if (!opts.sqlOnly) { + const schemaPath = path.join( + db_dir, + AppData.DbSchemaFileName, + ); + fs.writeFileSync(schemaPath, schemaTs, "utf-8"); + console.log( + chalk.green(`Schema written → ${schemaPath}`), + ); + } + + console.log( + `${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`, + ); + process.exit(0); + } catch (error: any) { + console.error(chalk.red(`Import ERROR => ${error.message}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 219afeb..58728fe 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,6 +5,8 @@ import schema from "./schema"; import typedef from "./typedef"; import backup from "./backup"; import restore from "./restore"; +import exportCmd from "./export"; +import importCmd from "./import"; import admin from "./admin"; import init from "../functions/init"; @@ -25,6 +27,8 @@ program.addCommand(schema()); program.addCommand(typedef()); program.addCommand(backup()); program.addCommand(restore()); +program.addCommand(exportCmd()); +program.addCommand(importCmd()); program.addCommand(admin()); /** diff --git a/src/commands/restore.ts b/src/commands/restore.ts index 6f2005c..75ee909 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -6,30 +6,7 @@ import grabSortedBackups from "../utils/grab-sorted-backups"; import { select } from "@inquirer/prompts"; import grabBackupData from "../utils/grab-backup-data"; import path from "path"; -import mariadbCliEnv, { - mariadbCliConnectionArgs, -} from "../utils/mariadb-cli-env"; - -/** - * Prefer mariadb client, fall back to mysql. - */ -function resolveClientBinary(): string { - const candidates = ["mariadb", "mysql"]; - for (const bin of candidates) { - try { - const result = Bun.spawnSync(["which", bin], { - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode === 0) { - return new TextDecoder().decode(result.stdout).trim() || bin; - } - } catch { - // try next - } - } - return "mariadb"; -} +import { restoreDatabase } from "../utils/mariadb-dump-restore"; export default function () { return new Command("restore") @@ -71,35 +48,8 @@ export default function () { process.exit(1); } - const clientBin = resolveClientBinary(); const sql = fs.readFileSync(backup_path, "utf-8"); - - const args = [ - clientBin, - ...mariadbCliConnectionArgs(), - config.db_name, - ]; - - const proc = Bun.spawn(args, { - stdin: new Blob([sql]), - stdout: "pipe", - stderr: "pipe", - env: mariadbCliEnv(), - }); - - const [stderr, exitCode] = await Promise.all([ - new Response(proc.stderr).text(), - proc.exited, - ]); - - if (exitCode !== 0) { - console.error( - chalk.red( - `Restore failed (exit ${exitCode}): ${stderr || "unknown error"}`, - ), - ); - process.exit(1); - } + await restoreDatabase(config, sql); console.log( `${chalk.bold(chalk.green(`DB Restore Success!`))} ← ${selected_backup}`, diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 2452228..c9e7eac 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -1,7 +1,9 @@ export const AppData = { ConfigFileName: "bun-mariadb.config.ts", MaxBackups: 10, + MaxExports: 10, DefaultBackupDirName: ".backups", + DefaultExportDirName: ".exports", DbSchemaManagerTableName: "__db_schema_manager__", DbSchemaFileName: "schema.ts", MaxInitRetries: 50, diff --git a/src/functions/init.ts b/src/functions/init.ts index 9c5a47b..c710da6 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -95,6 +95,14 @@ export default function init(): void { fs.mkdirSync(BackupDir, { recursive: true }); } + const ExportDir = path.resolve( + db_dir, + AppData["DefaultExportDirName"], + ); + if (!fs.existsSync(ExportDir)) { + fs.mkdirSync(ExportDir, { recursive: true }); + } + global.CONFIG = Config; global.DB_SCHEMA = DbSchema; diff --git a/src/types/index.ts b/src/types/index.ts index 6f84ba3..aef26ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1518,6 +1518,10 @@ export type BunMariaDBConfig = { */ db_backup_dir?: string; max_backups?: number; + /** + * Max export archives to keep in `{db_dir}/.exports` (default: 10) + */ + max_exports?: number; /** * Root directory for schema, types, and local artifacts (relative to project root) */ diff --git a/src/utils/export-archive.ts b/src/utils/export-archive.ts new file mode 100644 index 0000000..e5f4c9f --- /dev/null +++ b/src/utils/export-archive.ts @@ -0,0 +1,252 @@ +import fs from "fs"; +import path from "path"; +import { AppData } from "../data/app-data"; +import grabDirNames from "../data/grab-dir-names"; + +export const ExportArchiveMembers = { + SqlFileName: "dump.sql", + SchemaFileName: AppData.DbSchemaFileName, +} as const; + +export type ExportArchiveContents = { + sql: string; + schemaTs: string; +}; + +const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"] as const; + +export function isArchivePath(filePath: string): boolean { + const lower = filePath.toLowerCase(); + return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext)); +} + +export function isSqlPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".sql"); +} + +/** + * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema.ts. + */ +export async function writeExportArchive({ + contents, + outPath, +}: { + contents: ExportArchiveContents; + outPath: string; +}): Promise { + const lower = outPath.toLowerCase(); + if (lower.endsWith(".zip")) { + await writeZipArchive({ contents, outPath }); + return; + } + + const members = { + [ExportArchiveMembers.SqlFileName]: contents.sql, + [ExportArchiveMembers.SchemaFileName]: contents.schemaTs, + }; + + const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz"); + if (gzip) { + await Bun.Archive.write(outPath, members, { compress: "gzip" }); + } else { + await Bun.Archive.write(outPath, members); + } +} + +/** + * Read a portable export archive (tar / tar.gz / zip). + */ +export async function readExportArchive( + archivePath: string, +): Promise { + const lower = archivePath.toLowerCase(); + + if (lower.endsWith(".zip")) { + return readZipArchive(archivePath); + } + + const bytes = await Bun.file(archivePath).bytes(); + const archive = new Bun.Archive(bytes); + const files = await archive.files(); + + const sql = + (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ?? + (await readFirstMatching(files, (name) => name.endsWith(".sql"))); + + const schemaTs = + (await readArchiveMember( + files, + ExportArchiveMembers.SchemaFileName, + )) ?? + (await readFirstMatching(files, (name) => name.endsWith("schema.ts"))); + + if (!sql) { + throw new Error( + `Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`, + ); + } + + if (!schemaTs) { + throw new Error( + `Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`, + ); + } + + return { sql, schemaTs }; +} + +async function readArchiveMember( + files: Map, + name: string, +): Promise { + // Exact match, or basename match for nested paths + for (const [entry, file] of files) { + if (entry === name || path.basename(entry) === name) { + return await file.text(); + } + } + return null; +} + +async function readFirstMatching( + files: Map, + predicate: (name: string) => boolean, +): Promise { + for (const [entry, file] of files) { + if (predicate(entry) || predicate(path.basename(entry))) { + return await file.text(); + } + } + return null; +} + +async function writeZipArchive({ + contents, + outPath, +}: { + contents: ExportArchiveContents; + outPath: string; +}): Promise { + const { BUN_MARIADB_TEMP_DIR } = grabDirNames(); + const tempDir = path.join( + BUN_MARIADB_TEMP_DIR, + `export-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName); + const schemaPath = path.join( + tempDir, + ExportArchiveMembers.SchemaFileName, + ); + fs.writeFileSync(sqlPath, contents.sql, "utf-8"); + fs.writeFileSync(schemaPath, contents.schemaTs, "utf-8"); + + const absOut = path.resolve(outPath); + const proc = Bun.spawn( + [ + "zip", + "-q", + "-j", + absOut, + ExportArchiveMembers.SqlFileName, + ExportArchiveMembers.SchemaFileName, + ], + { + cwd: tempDir, + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode !== 0) { + throw new Error( + `zip failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`zip\` is installed, or use .tar.gz.`, + ); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function readZipArchive( + archivePath: string, +): Promise { + const { BUN_MARIADB_TEMP_DIR } = grabDirNames(); + const tempDir = path.join( + BUN_MARIADB_TEMP_DIR, + `import-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + fs.mkdirSync(tempDir, { recursive: true }); + + try { + const absArchive = path.resolve(archivePath); + const proc = Bun.spawn(["unzip", "-q", "-o", absArchive, "-d", tempDir], { + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode !== 0) { + throw new Error( + `unzip failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`unzip\` is installed.`, + ); + } + + const sql = findFileContents(tempDir, (name) => + name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql"), + ); + const schemaTs = findFileContents( + tempDir, + (name) => + name === ExportArchiveMembers.SchemaFileName || + name.endsWith("schema.ts"), + ); + + if (!sql) { + throw new Error( + `Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`, + ); + } + if (!schemaTs) { + throw new Error( + `Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`, + ); + } + + return { sql, schemaTs }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function findFileContents( + dir: string, + predicate: (baseName: string) => boolean, +): string | null { + const stack = [dir]; + while (stack.length) { + const current = stack.pop()!; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (predicate(entry.name)) { + return fs.readFileSync(full, "utf-8"); + } + } + } + return null; +} diff --git a/src/utils/grab-backup-data.ts b/src/utils/grab-backup-data.ts index ba00f00..a0150f7 100644 --- a/src/utils/grab-backup-data.ts +++ b/src/utils/grab-backup-data.ts @@ -3,10 +3,33 @@ type Params = { }; /** - * Parse timestamped backup names: `{db_name}-{timestamp}[.sql]` + * Strip known backup/export extensions from a file name. + */ +function stripBackupExtension(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith(".tar.gz")) { + return name.slice(0, -7); + } + if (lower.endsWith(".tgz")) { + return name.slice(0, -4); + } + if (lower.endsWith(".tar")) { + return name.slice(0, -4); + } + if (lower.endsWith(".zip")) { + return name.slice(0, -4); + } + if (lower.endsWith(".sql")) { + return name.slice(0, -4); + } + return name; +} + +/** + * Parse timestamped backup/export names: `{db_name}-{timestamp}[.sql|.tar.gz|...]` */ export default function grabBackupData({ backup_name }: Params) { - const normalized = backup_name.replace(/\.sql$/, ""); + const normalized = stripBackupExtension(backup_name); const backup_parts = normalized.split("-"); const backup_date_timestamp = Number(backup_parts.pop()); const origin_backup_name = backup_parts.join("-"); diff --git a/src/utils/grab-db-dir.ts b/src/utils/grab-db-dir.ts index 2a6cae7..7407ab3 100644 --- a/src/utils/grab-db-dir.ts +++ b/src/utils/grab-db-dir.ts @@ -18,6 +18,7 @@ export default function grabDBDir({ config }: Params) { config.db_backup_dir || AppData["DefaultBackupDirName"]; const backup_dir = path.resolve(db_dir, backup_dir_name); + const export_dir = path.resolve(db_dir, AppData["DefaultExportDirName"]); - return { db_dir, backup_dir }; + return { db_dir, backup_dir, export_dir }; } diff --git a/src/utils/grab-sorted-backups.ts b/src/utils/grab-sorted-backups.ts index 6db916f..caeae2a 100644 --- a/src/utils/grab-sorted-backups.ts +++ b/src/utils/grab-sorted-backups.ts @@ -6,8 +6,18 @@ type Params = { config: BunMariaDBConfig; }; +function stripBackupExtension(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith(".tar.gz")) return name.slice(0, -7); + if (lower.endsWith(".tgz")) return name.slice(0, -4); + if (lower.endsWith(".tar")) return name.slice(0, -4); + if (lower.endsWith(".zip")) return name.slice(0, -4); + if (lower.endsWith(".sql")) return name.slice(0, -4); + return name; +} + function backupTimestamp(name: string): number { - const base = name.replace(/\.sql$/, ""); + const base = stripBackupExtension(name); const ts = Number(base.split("-").pop()); return Number.isFinite(ts) ? ts : 0; } diff --git a/src/utils/grab-sorted-exports.ts b/src/utils/grab-sorted-exports.ts new file mode 100644 index 0000000..ea1ba4d --- /dev/null +++ b/src/utils/grab-sorted-exports.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import type { BunMariaDBConfig } from "../types"; +import grabDBDir from "./grab-db-dir"; + +type Params = { + config: BunMariaDBConfig; +}; + +function stripExportExtension(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith(".tar.gz")) return name.slice(0, -7); + if (lower.endsWith(".tgz")) return name.slice(0, -4); + if (lower.endsWith(".tar")) return name.slice(0, -4); + if (lower.endsWith(".zip")) return name.slice(0, -4); + return name; +} + +function exportTimestamp(name: string): number { + const base = stripExportExtension(name); + const ts = Number(base.split("-").pop()); + return Number.isFinite(ts) ? ts : 0; +} + +export default function grabSortedExports({ config }: Params) { + const { export_dir } = grabDBDir({ config }); + + if (!fs.existsSync(export_dir)) { + return [] as string[]; + } + + const exports = fs.readdirSync(export_dir); + + return exports.sort((a, b) => exportTimestamp(b) - exportTimestamp(a)); +} diff --git a/src/utils/mariadb-dump-restore.ts b/src/utils/mariadb-dump-restore.ts new file mode 100644 index 0000000..5ac7d8a --- /dev/null +++ b/src/utils/mariadb-dump-restore.ts @@ -0,0 +1,109 @@ +import type { BunMariaDBConfig } from "../types"; +import mariadbCliEnv, { mariadbCliConnectionArgs } from "./mariadb-cli-env"; + +/** + * Prefer mariadb-dump, fall back to mysqldump. + */ +export function resolveDumpBinary(): string { + const candidates = ["mariadb-dump", "mysqldump"]; + for (const bin of candidates) { + try { + const result = Bun.spawnSync(["which", bin], { + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode === 0) { + return new TextDecoder().decode(result.stdout).trim() || bin; + } + } catch { + // try next + } + } + return "mariadb-dump"; +} + +/** + * Prefer mariadb client, fall back to mysql. + */ +export function resolveClientBinary(): string { + const candidates = ["mariadb", "mysql"]; + for (const bin of candidates) { + try { + const result = Bun.spawnSync(["which", bin], { + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode === 0) { + return new TextDecoder().decode(result.stdout).trim() || bin; + } + } catch { + // try next + } + } + return "mariadb"; +} + +/** + * Dump the configured database to an SQL string. + */ +export async function dumpDatabase(config: BunMariaDBConfig): Promise { + const dumpBin = resolveDumpBinary(); + const args = [ + dumpBin, + ...mariadbCliConnectionArgs(), + "--single-transaction", + "--routines", + "--triggers", + "--events", + config.db_name, + ]; + + const proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + env: mariadbCliEnv(), + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode !== 0) { + throw new Error( + `Dump failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`mariadb-dump\` or \`mysqldump\` is installed.`, + ); + } + + return stdout; +} + +/** + * Restore an SQL dump into the configured database. + */ +export async function restoreDatabase( + config: BunMariaDBConfig, + sql: string, +): Promise { + const clientBin = resolveClientBinary(); + const args = [clientBin, ...mariadbCliConnectionArgs(), config.db_name]; + + const proc = Bun.spawn(args, { + stdin: new Blob([sql]), + stdout: "pipe", + stderr: "pipe", + env: mariadbCliEnv(), + }); + + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode !== 0) { + throw new Error( + `Restore failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`mariadb\` or \`mysql\` is installed.`, + ); + } +} diff --git a/src/utils/trim-exports.ts b/src/utils/trim-exports.ts new file mode 100644 index 0000000..7b2c289 --- /dev/null +++ b/src/utils/trim-exports.ts @@ -0,0 +1,24 @@ +import fs from "fs"; +import path from "path"; +import type { BunMariaDBConfig } from "../types"; +import { AppData } from "../data/app-data"; +import grabDBDir from "./grab-db-dir"; +import grabSortedExports from "./grab-sorted-exports"; + +type Params = { + config: BunMariaDBConfig; +}; + +export default function trimExports({ config }: Params) { + const { export_dir } = grabDBDir({ config }); + const exports = grabSortedExports({ config }); + const max_exports = config.max_exports || AppData["MaxExports"]; + + for (let i = 0; i < exports.length; i++) { + const export_name = exports[i]; + if (!export_name) continue; + if (i > max_exports - 1) { + fs.unlinkSync(path.join(export_dir, export_name)); + } + } +}