Files
bun-mariadb/dist/utils/export-archive.js
T
tbenandClaude Opus 5.5 ddf431e866 Add tests, schema sync safeguards, and query/handler improvements
- Add bun test suite: SQL generator and value unit tests, plus MariaDB
  integration tests gated on BUN_MARIADB_TEST_DB
- schema: add --dry-run and require confirmation (or --yes) before
  dropping tables/columns; block unapproved destructive statements
- Always AND targetId with the query, regardless of searchOperator
- Initialize lazily on first call; init throws instead of exiting
- Generate IN (?, ?, ...) lists for IN / NOT IN
- Share value binding between insert and update (booleans, embeddings,
  function values, "" -> NULL for non-text columns)
- Keep Buffers and convert BigInt/Date without a JSON round-trip
- DbSQL: report success for empty SELECT results
- Run update() in a transaction with row locking
- Key the client cache on the full connection config
- Consolidate identifier quoting, drop dead code, merge column lookup
- Read CLI version from package.json, pin @types/bun, chmod CLI on compile
- Rebuild dist

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 02:27:59 +01:00

200 lines
7.1 KiB
JavaScript

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 = {
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 file.
*/
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,
[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) {
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 schemaEntry = await readFirstSchemaMember(files);
if (!sql) {
throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`);
}
if (!schemaEntry) {
throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`);
}
return {
sql,
schema: schemaEntry.content,
schemaFileName: schemaEntry.fileName,
};
}
async function readArchiveMember(files, name) {
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 readFirstSchemaMember(files) {
for (const [entry, file] of files) {
const base = path.basename(entry);
if (isSchemaFileName(base)) {
return {
content: await file.text(),
fileName: base === AppData.DbSchemaFileName ? "schema.ts" : base,
};
}
}
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, contents.schemaFileName);
fs.writeFileSync(sqlPath, contents.sql, "utf-8");
fs.writeFileSync(schemaPath, contents.schema, "utf-8");
const absOut = path.resolve(outPath);
const proc = Bun.spawn([
"zip",
"-q",
"-j",
absOut,
ExportArchiveMembers.SqlFileName,
contents.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 schemaHit = findSchemaFile(tempDir);
if (!sql) {
throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`);
}
if (!schemaHit) {
throw new Error(`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`);
}
return {
sql,
schema: schemaHit.content,
schemaFileName: schemaHit.fileName,
};
}
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);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) {
stack.push(full);
}
else if (entry.isFile() && predicate(entry.name)) {
return fs.readFileSync(full, "utf-8");
}
}
}
return null;
}
function findSchemaFile(dir) {
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 (entry.isFile() && isSchemaFileName(entry.name)) {
return {
content: fs.readFileSync(full, "utf-8"),
fileName: entry.name === AppData.DbSchemaFileName
? "schema.ts"
: entry.name,
};
}
}
}
return null;
}