feat(backup): add scheduled database backups
This commit is contained in:
1 parent
cc518e8753
commit
f08a0b7f7f
26 files changed
+2491
-137
No files matched your search
@@ -125,6 +125,7 @@ export const deleteSavedSqlFile = forward("deleteSavedSqlFile");
|
||||
export const savedSqlStorageDir = forward("savedSqlStorageDir");
|
||||
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
|
||||
export const revealPathInFileManager = forward("revealPathInFileManager");
|
||||
export const deleteDatabaseBackupFiles = forward("deleteDatabaseBackupFiles");
|
||||
export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile");
|
||||
export const backupSqliteDatabase = forward("backupSqliteDatabase");
|
||||
export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
|
||||
@@ -326,6 +327,7 @@ export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
|
||||
// Database Export
|
||||
export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot");
|
||||
export const exportDatabaseSql = forward("exportDatabaseSql");
|
||||
export const cancelDatabaseExport = forward("cancelDatabaseExport");
|
||||
export const exportQueryResultCsv = forward("exportQueryResultCsv");
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
DatabaseBackupSnapshot,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportRequest,
|
||||
@@ -526,6 +527,10 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
|
||||
throw new Error("Reveal in file manager is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function deleteDatabaseBackupFiles(_paths: string[]): Promise<number> {
|
||||
throw new Error("Database backup file management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(_path: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -1561,6 +1566,10 @@ export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
// Database Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function beginDatabaseBackupSnapshot(_connectionId: string, _database: string): Promise<DatabaseBackupSnapshot> {
|
||||
throw new Error("Consistent database backup snapshots are only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
|
||||
// 1. POST to start the export
|
||||
const res = await fetch(apiUrl("/api/export/database"), {
|
||||
|
||||
@@ -1372,6 +1372,10 @@ export async function revealPathInFileManager(path: string): Promise<void> {
|
||||
return invoke("reveal_path_in_file_manager", { path });
|
||||
}
|
||||
|
||||
export async function deleteDatabaseBackupFiles(paths: string[]): Promise<number> {
|
||||
return invoke("delete_database_backup_files", { paths });
|
||||
}
|
||||
|
||||
export async function isSqliteDatabaseFile(path: string): Promise<boolean> {
|
||||
return invoke("is_sqlite_database_file", { path });
|
||||
}
|
||||
@@ -2286,9 +2290,16 @@ export interface DatabaseExportRequest {
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
dropTableIfExists?: boolean;
|
||||
failOnError?: boolean;
|
||||
snapshotSessionId?: string;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupSnapshot {
|
||||
sessionId: string;
|
||||
schemas: string[];
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
exportId: string;
|
||||
currentObject: string;
|
||||
@@ -2454,6 +2465,10 @@ export async function cancelQueryResultExport(exportId: string, executionId?: st
|
||||
return invoke("cancel_query_result_export", { exportId, executionId: executionId || null });
|
||||
}
|
||||
|
||||
export async function beginDatabaseBackupSnapshot(connectionId: string, database: string): Promise<DatabaseBackupSnapshot> {
|
||||
return invoke("begin_database_backup_snapshot", { connectionId, database });
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
|
||||
const unlisten: UnlistenFn = await listen<ExportProgress>("database-export-progress", (event) => {
|
||||
if (event.payload.exportId === request.exportId) {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const DATABASE_BACKUP_SCHEDULES_STORAGE_KEY = "dbx-database-backup-schedules";
|
||||
export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
|
||||
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
|
||||
export const MAX_DATABASE_BACKUP_HISTORY = 200;
|
||||
|
||||
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
|
||||
export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled";
|
||||
export type DatabaseBackupRunTrigger = "manual" | "scheduled";
|
||||
|
||||
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
|
||||
|
||||
export function supportsScheduledDatabaseBackup(databaseType: string | undefined): boolean {
|
||||
return !!databaseType && CONSISTENT_BACKUP_DATABASE_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[]): string[] {
|
||||
const available = [...new Set(availableDatabases.map((database) => database.trim()).filter(Boolean))];
|
||||
if (configuredDatabases.length === 0) return available;
|
||||
|
||||
const missing = configuredDatabases.filter((database) => !available.includes(database));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Configured backup databases are unavailable: ${missing.join(", ")}`);
|
||||
}
|
||||
return [...configuredDatabases];
|
||||
}
|
||||
|
||||
export interface DatabaseBackupSchedule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
connectionId: string;
|
||||
databases: string[];
|
||||
destinationDirectory: string;
|
||||
frequency: DatabaseBackupFrequency;
|
||||
intervalHours: number;
|
||||
timeOfDay: string;
|
||||
weekday: number;
|
||||
includeStructure: boolean;
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
dropTableIfExists: boolean;
|
||||
retentionCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nextRunAt: string;
|
||||
lastRunAt?: string;
|
||||
lastRunStatus?: Exclude<DatabaseBackupRunStatus, "running">;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupFile {
|
||||
database: string;
|
||||
schema: string;
|
||||
displayName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface DatabaseBackupRun {
|
||||
id: string;
|
||||
scheduleId: string;
|
||||
scheduleName: string;
|
||||
connectionId: string;
|
||||
connectionName: string;
|
||||
trigger: DatabaseBackupRunTrigger;
|
||||
status: DatabaseBackupRunStatus;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
files: DatabaseBackupFile[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function recordValue(value: unknown): UnknownRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as UnknownRecord) : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.round(numberValue)));
|
||||
}
|
||||
|
||||
function validIsoDate(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupTime(value: unknown): string {
|
||||
const text = stringValue(value).trim();
|
||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : "02:00";
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupRetention(value: unknown): number {
|
||||
return boundedInteger(value, 10, 1, 100);
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()): DatabaseBackupSchedule | null {
|
||||
const input = recordValue(value);
|
||||
const id = stringValue(input.id).trim();
|
||||
const connectionId = stringValue(input.connectionId).trim();
|
||||
const destinationDirectory = stringValue(input.destinationDirectory).trim();
|
||||
if (!id || !connectionId || !destinationDirectory) return null;
|
||||
|
||||
const nowIso = now.toISOString();
|
||||
const frequency: DatabaseBackupFrequency = input.frequency === "daily" || input.frequency === "weekly" ? input.frequency : "hourly";
|
||||
const schedule: DatabaseBackupSchedule = {
|
||||
id,
|
||||
name: stringValue(input.name).trim() || "Database backup",
|
||||
enabled: booleanValue(input.enabled, true),
|
||||
connectionId,
|
||||
databases: Array.isArray(input.databases) ? [...new Set(input.databases.map((database) => stringValue(database).trim()).filter(Boolean))] : [],
|
||||
destinationDirectory,
|
||||
frequency,
|
||||
intervalHours: boundedInteger(input.intervalHours, 6, 1, 168),
|
||||
timeOfDay: normalizeDatabaseBackupTime(input.timeOfDay),
|
||||
weekday: boundedInteger(input.weekday, 1, 0, 6),
|
||||
includeStructure: booleanValue(input.includeStructure, true),
|
||||
includeData: booleanValue(input.includeData, true),
|
||||
includeObjects: booleanValue(input.includeObjects, true),
|
||||
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
|
||||
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
|
||||
createdAt: validIsoDate(input.createdAt, nowIso),
|
||||
updatedAt: validIsoDate(input.updatedAt, nowIso),
|
||||
nextRunAt: validIsoDate(input.nextRunAt, ""),
|
||||
lastRunAt: typeof input.lastRunAt === "string" && Number.isFinite(Date.parse(input.lastRunAt)) ? input.lastRunAt : undefined,
|
||||
lastRunStatus: input.lastRunStatus === "success" || input.lastRunStatus === "failed" || input.lastRunStatus === "cancelled" ? input.lastRunStatus : undefined,
|
||||
};
|
||||
|
||||
if (!schedule.nextRunAt) schedule.nextRunAt = nextDatabaseBackupRunAt(schedule, now).toISOString();
|
||||
return schedule;
|
||||
}
|
||||
|
||||
export function nextDatabaseBackupRunAt(schedule: Pick<DatabaseBackupSchedule, "frequency" | "intervalHours" | "timeOfDay" | "weekday">, after = new Date()): Date {
|
||||
if (schedule.frequency === "hourly") {
|
||||
return new Date(after.getTime() + boundedInteger(schedule.intervalHours, 6, 1, 168) * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const [hours, minutes] = normalizeDatabaseBackupTime(schedule.timeOfDay).split(":").map(Number);
|
||||
const next = new Date(after);
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(hours!, minutes!, 0, 0);
|
||||
|
||||
if (schedule.frequency === "daily") {
|
||||
if (next.getTime() <= after.getTime()) next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
const weekday = boundedInteger(schedule.weekday, 1, 0, 6);
|
||||
let daysAhead = (weekday - next.getDay() + 7) % 7;
|
||||
if (daysAhead === 0 && next.getTime() <= after.getTime()) daysAhead = 7;
|
||||
next.setDate(next.getDate() + daysAhead);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function databaseBackupScheduleIsDue(schedule: DatabaseBackupSchedule, now = new Date()): boolean {
|
||||
return schedule.enabled && Date.parse(schedule.nextRunAt) <= now.getTime();
|
||||
}
|
||||
|
||||
function normalizeDatabaseBackupFile(value: unknown): DatabaseBackupFile | null {
|
||||
const input = recordValue(value);
|
||||
const filePath = stringValue(input.filePath).trim();
|
||||
if (!filePath) return null;
|
||||
return {
|
||||
database: stringValue(input.database).trim(),
|
||||
schema: stringValue(input.schema).trim(),
|
||||
displayName: stringValue(input.displayName).trim() || stringValue(input.database).trim(),
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | null {
|
||||
const input = recordValue(value);
|
||||
const id = stringValue(input.id).trim();
|
||||
const scheduleId = stringValue(input.scheduleId).trim();
|
||||
const startedAt = validIsoDate(input.startedAt, "");
|
||||
if (!id || !scheduleId || !startedAt) return null;
|
||||
const status: DatabaseBackupRunStatus = input.status === "success" || input.status === "failed" || input.status === "cancelled" ? input.status : "failed";
|
||||
return {
|
||||
id,
|
||||
scheduleId,
|
||||
scheduleName: stringValue(input.scheduleName).trim() || "Database backup",
|
||||
connectionId: stringValue(input.connectionId).trim(),
|
||||
connectionName: stringValue(input.connectionName).trim(),
|
||||
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
|
||||
status,
|
||||
startedAt,
|
||||
completedAt: typeof input.completedAt === "string" && Number.isFinite(Date.parse(input.completedAt)) ? input.completedAt : undefined,
|
||||
files: Array.isArray(input.files) ? input.files.map(normalizeDatabaseBackupFile).filter((file): file is DatabaseBackupFile => !!file) : [],
|
||||
error: stringValue(input.error).trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredArray(key: string): unknown[] {
|
||||
const stored = safeLocalStorageGet(key);
|
||||
if (!stored) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function readDatabaseBackupSchedules(): DatabaseBackupSchedule[] {
|
||||
return parseStoredArray(DATABASE_BACKUP_SCHEDULES_STORAGE_KEY)
|
||||
.map((schedule) => normalizeDatabaseBackupSchedule(schedule))
|
||||
.filter((schedule): schedule is DatabaseBackupSchedule => !!schedule);
|
||||
}
|
||||
|
||||
export function writeDatabaseBackupSchedules(schedules: readonly DatabaseBackupSchedule[]) {
|
||||
safeLocalStorageSet(DATABASE_BACKUP_SCHEDULES_STORAGE_KEY, JSON.stringify(schedules));
|
||||
}
|
||||
|
||||
export function readDatabaseBackupRuns(): DatabaseBackupRun[] {
|
||||
return parseStoredArray(DATABASE_BACKUP_RUNS_STORAGE_KEY)
|
||||
.map(normalizeDatabaseBackupRun)
|
||||
.filter((run): run is DatabaseBackupRun => !!run)
|
||||
.slice(0, MAX_DATABASE_BACKUP_HISTORY);
|
||||
}
|
||||
|
||||
export function writeDatabaseBackupRuns(runs: readonly DatabaseBackupRun[]) {
|
||||
safeLocalStorageSet(DATABASE_BACKUP_RUNS_STORAGE_KEY, JSON.stringify(runs.slice(0, MAX_DATABASE_BACKUP_HISTORY)));
|
||||
}
|
||||
|
||||
export function databaseBackupRunsToPrune(runs: readonly DatabaseBackupRun[], scheduleId: string, retentionCount: number): DatabaseBackupRun[] {
|
||||
return runs
|
||||
.filter((run) => run.scheduleId === scheduleId && run.status === "success")
|
||||
.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))
|
||||
.slice(normalizeDatabaseBackupRetention(retentionCount));
|
||||
}
|
||||
|
||||
export function sanitizeDatabaseBackupFileSegment(value: string): string {
|
||||
const printableValue = Array.from(value, (character) => (character.charCodeAt(0) < 32 ? "_" : character)).join("");
|
||||
const sanitized = printableValue
|
||||
.replace(/[\\/:*?"<>|]+/g, "_")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.trim();
|
||||
return sanitized || "database";
|
||||
}
|
||||
|
||||
export function databaseBackupTimestamp(value: Date | string): string {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const part = (number: number) => String(number).padStart(2, "0");
|
||||
return `${date.getFullYear()}${part(date.getMonth() + 1)}${part(date.getDate())}-${part(date.getHours())}${part(date.getMinutes())}${part(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function joinDatabaseBackupPath(directory: string, fileName: string): string {
|
||||
const separator = directory.includes("\\") ? "\\" : "/";
|
||||
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
|
||||
}
|
||||
|
||||
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string): string {
|
||||
const fileName = `dbx-backup__${sanitizeDatabaseBackupFileSegment(scheduleName)}__${databaseBackupTimestamp(startedAt)}__${sanitizeDatabaseBackupFileSegment(fileStem)}__${sanitizeDatabaseBackupFileSegment(runId).slice(0, 8)}.sql`;
|
||||
return joinDatabaseBackupPath(directory, fileName);
|
||||
}
|
||||
@@ -85,6 +85,21 @@ export function generateDatabaseExportId(): string {
|
||||
return uuid();
|
||||
}
|
||||
|
||||
export async function runDatabaseExportUntilTerminal(request: api.DatabaseExportRequest, onProgress: (progress: api.ExportProgress) => void): Promise<api.ExportProgress> {
|
||||
return new Promise<api.ExportProgress>((resolve, reject) => {
|
||||
api
|
||||
.exportDatabaseSql(request, (progress) => {
|
||||
onProgress(progress);
|
||||
if (progress.status === "Done" || progress.status === "Cancelled") {
|
||||
resolve(progress);
|
||||
} else if (progress.status === "Error") {
|
||||
reject(new Error(progress.error || "Export failed"));
|
||||
}
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput): AllDatabaseExportPlanItem[] {
|
||||
return options.databases.flatMap((database) => {
|
||||
const schemas = options.schemaAware ? (options.schemasByDatabase?.[database] ?? []).filter((schema) => schema.trim()) : [database];
|
||||
|
||||
Reference in new issue
Block a user