171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
import fs from "fs/promises";
|
|
import path from "path";
|
|
import mysql, { type RowDataPacket } from "mysql2/promise";
|
|
|
|
/** Number of rows exported per INSERT statement to bound memory usage. */
|
|
const EXPORT_BATCH_SIZE = 500;
|
|
/** Keep a short local history while preventing unbounded disk growth. */
|
|
const MAX_BACKUP_FILES = 10;
|
|
|
|
export type DatabaseBackupResult = {
|
|
fileName: string;
|
|
filePath: string;
|
|
size: number;
|
|
tableCount: number;
|
|
};
|
|
|
|
/**
|
|
* Converts one MySQL value to a portable SQL literal.
|
|
* Binary values, booleans, dates, quotes and backslashes are handled explicitly
|
|
* so a generated dump can be imported without corrupting invoice data.
|
|
*/
|
|
export function toSqlLiteral(value: unknown): string {
|
|
if (value === null || value === undefined) return "NULL";
|
|
if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`;
|
|
if (value instanceof Date) {
|
|
return `'${value.toISOString().replace("T", " ").replace("Z", "")}'`;
|
|
}
|
|
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
|
if (typeof value === "boolean") return value ? "1" : "0";
|
|
|
|
return `'${String(value)
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/'/g, "\\'")
|
|
.replace(/\u0000/g, "\\0")
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\r/g, "\\r")}'`;
|
|
}
|
|
|
|
function quoteIdentifier(identifier: string): string {
|
|
if (!/^[A-Za-z0-9_$]+$/.test(identifier)) {
|
|
throw new Error("Identifiant SQL inattendu lors de la sauvegarde");
|
|
}
|
|
return `\`${identifier}\``;
|
|
}
|
|
|
|
function buildFileName(database: string, now = new Date()): string {
|
|
const safeDatabase = database.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
return `backup-${safeDatabase}-${timestamp}.sql`;
|
|
}
|
|
|
|
function buildSslConfig(databaseUrl: URL) {
|
|
const sslMode = databaseUrl.searchParams.get("ssl-mode")?.toUpperCase();
|
|
const sslParameter = databaseUrl.searchParams.get("ssl");
|
|
|
|
if (sslMode === "REQUIRED" || sslMode === "VERIFY_CA" || sslMode === "VERIFY_IDENTITY") {
|
|
return { rejectUnauthorized: sslMode === "VERIFY_IDENTITY" };
|
|
}
|
|
|
|
if (sslParameter && sslParameter !== "false") {
|
|
try {
|
|
return JSON.parse(sslParameter) as { rejectUnauthorized?: boolean };
|
|
} catch {
|
|
return { rejectUnauthorized: false };
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function pruneOldBackups(backupDir: string): Promise<void> {
|
|
const entries = await fs.readdir(backupDir, { withFileTypes: true });
|
|
const backups = await Promise.all(
|
|
entries
|
|
.filter(entry => entry.isFile() && entry.name.endsWith(".sql"))
|
|
.map(async entry => ({
|
|
name: entry.name,
|
|
modifiedAt: (await fs.stat(path.join(backupDir, entry.name))).mtimeMs,
|
|
}))
|
|
);
|
|
|
|
backups.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
|
await Promise.all(
|
|
backups.slice(MAX_BACKUP_FILES).map(backup => fs.unlink(path.join(backupDir, backup.name)))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Creates a self-contained SQL dump without requiring the mysqldump binary.
|
|
* Rows are exported in batches to avoid keeping the entire database in memory.
|
|
*/
|
|
export async function createDatabaseBackup(
|
|
databaseUrlValue: string | undefined,
|
|
backupDir: string
|
|
): Promise<DatabaseBackupResult> {
|
|
if (!databaseUrlValue) {
|
|
throw new Error("DATABASE_URL est absente");
|
|
}
|
|
|
|
const databaseUrl = new URL(databaseUrlValue);
|
|
if (!databaseUrl.protocol.startsWith("mysql")) {
|
|
throw new Error("La sauvegarde requiert une base de données MySQL compatible");
|
|
}
|
|
|
|
const database = databaseUrl.pathname.replace(/^\//, "");
|
|
if (!database) {
|
|
throw new Error("Nom de base de données absent de DATABASE_URL");
|
|
}
|
|
|
|
await fs.mkdir(backupDir, { recursive: true });
|
|
const fileName = buildFileName(database);
|
|
const filePath = path.join(backupDir, fileName);
|
|
const connection = await mysql.createConnection({
|
|
host: databaseUrl.hostname,
|
|
port: Number(databaseUrl.port || "3306"),
|
|
user: decodeURIComponent(databaseUrl.username),
|
|
password: decodeURIComponent(databaseUrl.password),
|
|
database,
|
|
ssl: buildSslConfig(databaseUrl),
|
|
});
|
|
|
|
try {
|
|
await fs.writeFile(
|
|
filePath,
|
|
`-- Backup généré le ${new Date().toISOString()}\n-- Base : ${database}\nSET FOREIGN_KEY_CHECKS=0;\n\n`,
|
|
"utf8"
|
|
);
|
|
|
|
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
|
|
const tableNames = tables.map(row => String(Object.values(row)[0]));
|
|
|
|
for (const tableName of tableNames) {
|
|
const table = quoteIdentifier(tableName);
|
|
const [createRows] = await connection.query<RowDataPacket[]>(`SHOW CREATE TABLE ${table}`);
|
|
const createStatement = String(createRows[0]?.["Create Table"] ?? "");
|
|
if (!createStatement) throw new Error(`Structure introuvable pour la table ${tableName}`);
|
|
|
|
await fs.appendFile(
|
|
filePath,
|
|
`-- Table: ${tableName}\nDROP TABLE IF EXISTS ${table};\n${createStatement};\n\n`,
|
|
"utf8"
|
|
);
|
|
|
|
let offset = 0;
|
|
while (true) {
|
|
const [rows] = await connection.query<RowDataPacket[]>(
|
|
`SELECT * FROM ${table} LIMIT ${EXPORT_BATCH_SIZE} OFFSET ${offset}`
|
|
);
|
|
if (rows.length === 0) break;
|
|
|
|
const columns = Object.keys(rows[0]!).map(quoteIdentifier).join(", ");
|
|
const values = rows
|
|
.map(row => `(${Object.values(row).map(toSqlLiteral).join(", ")})`)
|
|
.join(",\n");
|
|
await fs.appendFile(filePath, `INSERT INTO ${table} (${columns}) VALUES\n${values};\n\n`, "utf8");
|
|
offset += rows.length;
|
|
}
|
|
}
|
|
|
|
await fs.appendFile(filePath, "SET FOREIGN_KEY_CHECKS=1;\n-- Fin du dump\n", "utf8");
|
|
await pruneOldBackups(backupDir);
|
|
const { size } = await fs.stat(filePath);
|
|
return { fileName, filePath, size, tableCount: tableNames.length };
|
|
} catch (error) {
|
|
await fs.rm(filePath, { force: true });
|
|
throw error;
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|