import fs from "fs"; import path from "path"; import { AppData } from "../data/app-data"; import grabDirNames from "../data/grab-dir-names"; import { isSchemaFileName } from "./resolve-and-load-data-file"; export const ExportArchiveMembers = { DbFileName: "database.sqlite", SchemaFileName: AppData.DbSchemaFileName, }; const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"]; /** Every SQLite database file starts with this header */ const SQLITE_HEADER = "SQLite format 3\0"; 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"); } export function isSQLiteFile(bytes) { return (new TextDecoder().decode(bytes.subarray(0, SQLITE_HEADER.length)) === SQLITE_HEADER); } /** * Create a portable export archive (tar / tar.gz / zip) with the database * file + schema file. */ export async function writeExportArchive({ contents, outPath, }) { const lower = outPath.toLowerCase(); if (lower.endsWith(".zip")) { await writeZipArchive({ contents, outPath }); return; } const members = { [ExportArchiveMembers.DbFileName]: contents.db, [contents.schemaFileName]: contents.schema, }; 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) { if (archivePath.toLowerCase().endsWith(".zip")) { return readZipArchive(archivePath); } const bytes = await Bun.file(archivePath).bytes(); const archive = new Bun.Archive(bytes); const files = await archive.files(); let db = null; let schemaEntry = null; for (const [entry, file] of files) { const base = path.basename(entry); if (!db && base === ExportArchiveMembers.DbFileName) { db = await file.bytes(); } else if (!schemaEntry && isSchemaFileName(base)) { schemaEntry = { content: await file.text(), fileName: schemaMemberName(base), }; } } return validateContents({ db, schemaEntry }); } function schemaMemberName(base) { return base === AppData.DbSchemaFileName ? "schema.ts" : base; } function validateContents({ db, schemaEntry, }) { if (!db) { throw new Error(`Archive is missing the database file (expected \`${ExportArchiveMembers.DbFileName}\`)`); } if (!isSQLiteFile(db)) { throw new Error(`\`${ExportArchiveMembers.DbFileName}\` in the archive is not a SQLite database`); } if (!schemaEntry) { throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|js|json|yaml|yml\`)`); } return { db, schema: schemaEntry.content, schemaFileName: schemaEntry.fileName, }; } async function runCommand(cmd, cwd) { const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe", }); const [stderr, exitCode] = await Promise.all([ new Response(proc.stderr).text(), proc.exited, ]); if (exitCode !== 0) { throw new Error(`${cmd[0]} failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`${cmd[0]}\` is installed, or use .tar.gz.`); } } function makeTempDir(prefix) { const { BUN_SQLITE_TEMP_DIR } = grabDirNames(); const tempDir = path.join(BUN_SQLITE_TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(tempDir, { recursive: true }); return tempDir; } async function writeZipArchive({ contents, outPath, }) { const tempDir = makeTempDir("export"); try { fs.writeFileSync(path.join(tempDir, ExportArchiveMembers.DbFileName), contents.db); fs.writeFileSync(path.join(tempDir, contents.schemaFileName), contents.schema, "utf-8"); await runCommand([ "zip", "-q", "-j", path.resolve(outPath), ExportArchiveMembers.DbFileName, contents.schemaFileName, ], tempDir); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } } async function readZipArchive(archivePath) { const tempDir = makeTempDir("import"); try { await runCommand([ "unzip", "-q", "-o", path.resolve(archivePath), "-d", tempDir, ]); let db = null; let schemaEntry = null; const stack = [tempDir]; while (stack.length) { const current = stack.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true, })) { const full = path.join(current, entry.name); // Never follow symlinks from an extracted (untrusted) archive if (entry.isDirectory()) { stack.push(full); } else if (!entry.isFile()) { continue; } else if (!db && entry.name === ExportArchiveMembers.DbFileName) { db = new Uint8Array(fs.readFileSync(full)); } else if (!schemaEntry && isSchemaFileName(entry.name)) { schemaEntry = { content: fs.readFileSync(full, "utf-8"), fileName: schemaMemberName(entry.name), }; } } } return validateContents({ db, schemaEntry }); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } }