This commit is contained in:
Benjamin Toby 2026-07-14 09:34:10 +01:00
parent 3faf4be368
commit 225e3eba4c
16 changed files with 791 additions and 118 deletions

View File

@ -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 projects 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

View File

@ -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);
}
});

95
src/commands/export.ts Normal file
View File

@ -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 <path>",
"Output archive path (.tar.gz or .zip). Defaults to db exports dir",
)
.option(
"-f, --format <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);
}
});
}

143
src/commands/import.ts Normal file
View File

@ -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);
}
});
}

View File

@ -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());
/**

View File

@ -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}`,

View File

@ -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,

View File

@ -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;

View File

@ -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)
*/

252
src/utils/export-archive.ts Normal file
View File

@ -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<void> {
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<ExportArchiveContents> {
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<string, File>,
name: string,
): Promise<string | null> {
// 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<string, File>,
predicate: (name: string) => boolean,
): Promise<string | null> {
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<void> {
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<ExportArchiveContents> {
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;
}

View File

@ -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("-");

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -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));
}

View File

@ -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<string> {
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<void> {
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.`,
);
}
}

24
src/utils/trim-exports.ts Normal file
View File

@ -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));
}
}
}