174 lines
5.8 KiB
JavaScript
174 lines
5.8 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 = {
|
|
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 });
|
|
}
|
|
}
|