Updates
This commit is contained in:
parent
3faf4be368
commit
225e3eba4c
72
README.md
72
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
|
||||
|
||||
@ -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
95
src/commands/export.ts
Normal 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
143
src/commands/import.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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());
|
||||
|
||||
/**
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
252
src/utils/export-archive.ts
Normal 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;
|
||||
}
|
||||
@ -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("-");
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
34
src/utils/grab-sorted-exports.ts
Normal file
34
src/utils/grab-sorted-exports.ts
Normal 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));
|
||||
}
|
||||
109
src/utils/mariadb-dump-restore.ts
Normal file
109
src/utils/mariadb-dump-restore.ts
Normal 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
24
src/utils/trim-exports.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user