Files
dbx/apps/desktop/src/composables/useScheduledDatabaseBackups.ts
T

460 lines
19 KiB
TypeScript

import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import * as api from "@/lib/backend/api";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal } from "@/lib/export/databaseExport";
import {
DATABASE_BACKUP_CONFIG_CHANGED_EVENT,
DATABASE_BACKUP_RUNS_STORAGE_KEY,
DATABASE_BACKUP_SCHEDULES_STORAGE_KEY,
DatabaseBackupConnectionQueue,
databaseBackupAggregateExportStatus,
databaseBackupFilePath,
databaseBackupProgressPercent,
databaseBackupRunsToPrune,
databaseBackupScheduleIsDue,
databaseBackupTableNamesAreCaseSensitive,
nextDatabaseBackupRunAt,
normalizeDatabaseBackupSchedule,
readDatabaseBackupRuns,
readDatabaseBackupSchedules,
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
} from "@/lib/backup/scheduledDatabaseBackup";
import { useExportTracker } from "@/composables/useExportTracker";
const SCHEDULER_INTERVAL_MS = 30_000;
const databaseBackupConnectionQueue = new DatabaseBackupConnectionQueue();
const schedules = ref<DatabaseBackupSchedule[]>(readDatabaseBackupSchedules());
const runs = ref<DatabaseBackupRun[]>(readDatabaseBackupRuns());
const activeScheduleIds = reactive(new Set<string>());
const activeRunIds = reactive(new Set<string>());
const activeExportIds = new Map<string, string>();
const cancellationRequested = new Set<string>();
let schedulerTimer: ReturnType<typeof window.setInterval> | undefined;
let schedulerRegistered = false;
let processingDueSchedules = false;
function emitConfigChanged() {
if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(DATABASE_BACKUP_CONFIG_CHANGED_EVENT));
}
function persistSchedules() {
writeDatabaseBackupSchedules(schedules.value);
emitConfigChanged();
}
function persistRuns() {
writeDatabaseBackupRuns(runs.value);
}
function replaceRun(run: DatabaseBackupRun, persist = true) {
runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)];
if (persist) persistRuns();
}
function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = true): DatabaseBackupRun | null {
const existing = runs.value.find((run) => run.id === runId);
if (!existing) return null;
const updated = { ...existing, ...patch };
replaceRun(updated, persist);
return updated;
}
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
}
export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
const connectionStore = useConnectionStore();
const { addDatabaseExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
function saveSchedule(value: DatabaseBackupSchedule): DatabaseBackupSchedule {
const now = new Date();
const existing = schedules.value.find((schedule) => schedule.id === value.id);
const normalized = normalizeDatabaseBackupSchedule(
{
...value,
createdAt: existing?.createdAt ?? value.createdAt,
updatedAt: now.toISOString(),
},
now,
);
if (!normalized) throw new Error("Invalid database backup schedule");
const timingChanged = !existing || existing.frequency !== normalized.frequency || existing.intervalHours !== normalized.intervalHours || existing.timeOfDay !== normalized.timeOfDay || existing.weekday !== normalized.weekday || (!existing.enabled && normalized.enabled);
if (timingChanged) normalized.nextRunAt = nextDatabaseBackupRunAt(normalized, now).toISOString();
if (existing) {
normalized.lastRunAt = existing.lastRunAt;
normalized.lastRunStatus = existing.lastRunStatus;
}
schedules.value = [...schedules.value.filter((schedule) => schedule.id !== normalized.id), normalized].sort((left, right) => left.name.localeCompare(right.name));
persistSchedules();
return normalized;
}
function setScheduleEnabled(scheduleId: string, enabled: boolean) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
saveSchedule({ ...schedule, enabled });
}
function deleteSchedule(scheduleId: string): boolean {
if (activeScheduleIds.has(scheduleId)) return false;
schedules.value = schedules.value.filter((schedule) => schedule.id !== scheduleId);
persistSchedules();
return true;
}
async function deleteRun(runId: string): Promise<void> {
const run = runs.value.find((item) => item.id === runId);
if (!run || activeRunIds.has(runId)) return;
if (run.files.length > 0) await api.deleteDatabaseBackupFiles(run.files.map((file) => file.filePath));
runs.value = runs.value.filter((item) => item.id !== runId);
persistRuns();
}
function renameRun(runId: string, displayName: string): boolean {
const run = runs.value.find((item) => item.id === runId);
if (!run || activeRunIds.has(runId)) return false;
const normalizedName = displayName.trim();
updateRun(runId, { displayName: normalizedName && normalizedName !== run.scheduleName ? normalizedName : undefined });
return true;
}
async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
if (staleRuns.length === 0) return;
const stalePaths = staleRuns.flatMap((run) => run.files.map((file) => file.filePath));
try {
if (stalePaths.length > 0) await api.deleteDatabaseBackupFiles(stalePaths);
const staleIds = new Set(staleRuns.map((run) => run.id));
runs.value = runs.value.filter((run) => !staleIds.has(run.id));
persistRuns();
} catch (error) {
appendDebugLog("error", "[DBX][database-backup:retention-error]", error);
}
}
async function cancelRun(runId: string) {
if (!activeRunIds.has(runId)) return;
cancellationRequested.add(runId);
const exportId = activeExportIds.get(runId);
if (exportId) await api.cancelDatabaseExport(exportId).catch(() => {});
}
async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise<DatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
const connection = connectionStore.getConfig(schedule.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const run: DatabaseBackupRun = {
id: runId,
scheduleId: schedule.id,
scheduleName: schedule.name,
connectionId: schedule.connectionId,
connectionName: connection?.name ?? "",
trigger,
status: "running",
startedAt: startedAt.toISOString(),
files: [],
progressPercent: 0,
};
replaceRun(run);
activeScheduleIds.add(schedule.id);
activeRunIds.add(runId);
cancellationRequested.delete(runId);
addDatabaseExportTask(runId, schedule.name, schedule.destinationDirectory, "scheduled");
registerTaskCancelHandler(runId, () => cancelRun(runId));
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: DatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
await databaseBackupConnectionQueue.run(schedule.connectionId, async () => {
try {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
return;
}
if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
await connectionStore.ensureConnected(schedule.connectionId);
const availableDatabases = (await api.listDatabases(schedule.connectionId)).map((database) => database.name);
const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases, connection.db_type);
if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
let tableNamesCaseSensitive = true;
if (connection.db_type === "mysql" && schedule.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]);
} catch (error) {
appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
}
}
let exportIndex = 0;
let includedTableCount = 0;
for (const [databaseIndex, database] of selectedDatabases.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
schemasByDatabase,
});
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
for (const item of databasePlan) {
if (schedule.tableFilterMode === "all") {
scopedDatabasePlan.push(item);
continue;
}
const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
includedTableCount += scope.includedTables.length;
if (scope.includedTables.length === 0) continue;
scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
}
const snapshot = await api.beginDatabaseBackupSnapshot(schedule.connectionId, database);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
if (cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(schedule.destinationDirectory, schedule.name, item.fileStem, startedAt, runId);
generatedPaths.push(filePath);
const terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: schedule.connectionId,
database: item.database,
schema: item.schema,
filePath,
selectedTables: item.selectedTables,
excludedTables: item.excludedTables,
includeStructure: schedule.includeStructure,
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
failOnError: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
},
(progress) => {
const progressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex,
totalDatabases: selectedDatabases.length,
completedExports: planIndex,
totalExports: scopedDatabasePlan.length,
currentObjectIndex: progress.objectIndex,
currentTotalObjects: progress.totalObjects,
currentExportComplete: progress.status === "Done",
});
if (progressPercent !== lastProgressPercent) {
lastProgressPercent = progressPercent;
updateRun(runId, { progressPercent }, false);
}
updateDatabaseExportTask(runId, {
...progress,
exportId: runId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
status: databaseBackupAggregateExportStatus(progress.status, false),
overallPercent: progressPercent,
});
},
);
activeExportIds.delete(runId);
if (terminal.status === "Cancelled" || cancellationRequested.has(runId)) {
finalStatus = "cancelled";
break;
}
const file: DatabaseBackupFile = {
database: item.database,
schema: item.schema,
displayName: item.displayName,
filePath,
};
run.files.push(file);
updateRun(runId, { files: [...run.files] });
}
snapshotCompleted = finalStatus === "success";
} finally {
await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => {
if (snapshotCompleted) throw error;
});
}
if (finalStatus !== "success") break;
lastProgressPercent = databaseBackupProgressPercent({
completedDatabases: databaseIndex + 1,
totalDatabases: selectedDatabases.length,
completedExports: 0,
totalExports: 0,
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (schedule.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`);
}
} catch (error: any) {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
finalError = error?.message || String(error);
} finally {
if (finalStatus !== "success" && generatedPaths.length > 0) {
try {
await api.deleteDatabaseBackupFiles(generatedPaths);
run.files = [];
} catch (error: any) {
appendDebugLog("error", "[DBX][database-backup:partial-cleanup-error]", error);
const cleanupError = error?.message || String(error);
finalError = finalError ? `${finalError}; failed to remove partial backup files: ${cleanupError}` : cleanupError;
}
}
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
activeScheduleIds.delete(schedule.id);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
finishedRun = updateRun(runId, {
status: finalStatus,
completedAt: completedAt.toISOString(),
files: [...run.files],
progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
error: finalError || undefined,
});
updateDatabaseExportTask(runId, {
exportId: runId,
currentObject: schedule.name,
objectIndex: finalStatus === "success" ? run.files.length : 0,
totalObjects: run.files.length,
rowsExported: 0,
totalRows: null,
status: databaseBackupAggregateExportStatus(finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error", true),
error: finalError || null,
overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
});
const latestSchedule = schedules.value.find((item) => item.id === schedule.id);
if (latestSchedule) {
schedules.value = schedules.value.map((item) =>
item.id === schedule.id
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: finalStatus,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (finalStatus === "success") await pruneScheduleRuns(latestSchedule);
}
appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
scheduleId: schedule.id,
runId,
files: run.files.length,
error: finalError || undefined,
});
}
});
return finishedRun;
}
async function processDueSchedules() {
if (processingDueSchedules) return;
processingDueSchedules = true;
try {
await connectionStore.initFromDisk();
const now = new Date();
const dueSchedules = schedules.value.filter((schedule) => databaseBackupScheduleIsDue(schedule, now));
for (const schedule of dueSchedules) {
await runSchedule(schedule.id, "scheduled").catch((error) => {
appendDebugLog("error", "[DBX][database-backup:scheduler-error]", error);
});
}
} catch (error) {
appendDebugLog("error", "[DBX][database-backup:scheduler-init-error]", error);
} finally {
processingDueSchedules = false;
}
}
function onStorage(event: StorageEvent) {
if (event.key && event.key !== DATABASE_BACKUP_SCHEDULES_STORAGE_KEY && event.key !== DATABASE_BACKUP_RUNS_STORAGE_KEY) return;
refreshFromStorage();
void processDueSchedules();
}
function onConfigChanged() {
void processDueSchedules();
}
if (options.scheduler) {
onMounted(() => {
if (schedulerRegistered || !isTauriRuntime()) return;
schedulerRegistered = true;
window.addEventListener("storage", onStorage);
window.addEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
schedulerTimer = window.setInterval(() => void processDueSchedules(), SCHEDULER_INTERVAL_MS);
void processDueSchedules();
});
onUnmounted(() => {
if (!schedulerRegistered) return;
schedulerRegistered = false;
if (schedulerTimer) window.clearInterval(schedulerTimer);
schedulerTimer = undefined;
window.removeEventListener("storage", onStorage);
window.removeEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
});
}
return {
schedules,
runs,
activeScheduleIds,
activeRunIds,
activeRuns,
saveSchedule,
setScheduleEnabled,
deleteSchedule,
deleteRun,
renameRun,
runSchedule,
cancelRun,
processDueSchedules,
};
}