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, }; const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"]; export function isArchivePath(filePath) { const lower = filePath.toLowerCase(); return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext)); } export function isSqlPath(filePath) { 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, }) { 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) { 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, name) { // 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, predicate) { for (const [entry, file] of files) { if (predicate(entry) || predicate(path.basename(entry))) { return await file.text(); } } return null; } async function writeZipArchive({ contents, outPath, }) { 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) { 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, predicate) { 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; }