feat(backup): add one-shot database backups

This commit is contained in:
jearhe authored and GitHub committed 2026-08-18 15:30:44 +08:00
1 parent f0cc5aefcf
commit 0facea2740
17 files changed
+888 -179

No files matched your search

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { FolderOpen, Loader2 } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
withDefaults(
defineProps<{
draft: DatabaseBackupExecutionConfig;
connections: Array<Pick<ConnectionConfig, "id" | "name">>;
allDatabases: boolean;
selectedDatabases: string[];
databaseOptions: string[];
tablePatternsInput: string;
loadingDatabases: boolean;
}>(),
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [] },
);
const emit = defineEmits<{
changeConnection: [connectionId: string];
chooseDestination: [];
toggleDatabase: [database: string];
"update:allDatabases": [value: boolean];
"update:tablePatternsInput": [value: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => emit('changeConnection', String(value))">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in connections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.destination") }}</Label>
<div class="flex gap-2">
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input :checked="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" @change="emit('update:allDatabases', ($event.target as HTMLInputElement).checked)" />
{{ t("databaseBackup.allDatabases") }}
</label>
</div>
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="emit('toggleDatabase', database)" />
<span class="truncate">{{ database }}</span>
</label>
</div>
</div>
<div class="space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
<div class="space-y-2">
<Label>{{ t("databaseBackup.tableScope") }}</Label>
<Select v-model="draft.tableFilterMode">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
<Input :model-value="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" @update:model-value="(value: any) => emit('update:tablePatternsInput', String(value))" />
</div>
</div>
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
</div>
<div class="space-y-3">
<Label>{{ t("databaseBackup.contents") }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
</div>
</div>
</div>
</template>
@@ -11,19 +11,21 @@ import { Switch } from "@/components/ui/switch";
import { ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupExecutionConfig, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import { useConnectionStore } from "@/stores/connectionStore";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, cancelRun } = useScheduledDatabaseBackups();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const scheduleDialogOpen = ref(false);
const oneShotDialogOpen = ref(false);
const deleteScheduleDialogOpen = ref(false);
const deleteRunDialogOpen = ref(false);
const renameRunDialogOpen = ref(false);
@@ -34,6 +36,7 @@ const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
const renameRunName = ref("");
const loadingDatabases = ref(false);
const saving = ref(false);
const oneShotStarting = ref(false);
const databaseOptions = ref<string[]>([]);
const allDatabases = ref(true);
const selectedDatabases = ref<string[]>([]);
@@ -53,25 +56,31 @@ const weekdays = computed(() => [
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
]);
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
const now = new Date();
const draft: DatabaseBackupSchedule = {
id: generateDatabaseExportId(),
name: t("databaseBackup.defaultScheduleName"),
enabled: true,
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
return {
connectionId,
databases: [],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "",
frequency: "daily",
intervalHours: 6,
timeOfDay: "02:00",
weekday: 1,
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
};
}
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
const now = new Date();
const draft: DatabaseBackupSchedule = {
...newBackupConfig(connectionId),
id: generateDatabaseExportId(),
name: t("databaseBackup.defaultScheduleName"),
enabled: true,
frequency: "daily",
intervalHours: 6,
timeOfDay: "02:00",
weekday: 1,
retentionCount: 10,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
@@ -82,6 +91,18 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
}
const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
const oneShotDraft = ref<DatabaseBackupExecutionConfig>(newBackupConfig());
const activeDraft = computed<DatabaseBackupExecutionConfig>(() => (oneShotDialogOpen.value ? oneShotDraft.value : draft.value));
const activeOneShotRun = computed(() => activeRuns.value.find((run) => run.source === "one-shot"));
type BackupDialogKind = "schedule" | "one-shot";
let databaseLoadGeneration = 0;
function databaseLoadIsCurrent(generation: number, dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, connectionId: string): boolean {
if (generation !== databaseLoadGeneration) return false;
if (dialog === "one-shot") return oneShotDialogOpen.value && oneShotDraft.value === targetDraft && targetDraft.connectionId === connectionId;
return scheduleDialogOpen.value && draft.value === targetDraft && targetDraft.connectionId === connectionId;
}
const canSave = computed(() => {
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
@@ -89,6 +110,12 @@ const canSave = computed(() => {
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
});
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
const canStartOneShot = computed(() => {
const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects;
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
const hasTableScope = oneShotDraft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
return !!oneShotDraft.value.connectionId && !!oneShotDraft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !oneShotStarting.value && !loadingDatabases.value;
});
function connectionName(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
@@ -135,55 +162,67 @@ function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
}
async function loadDatabases(connectionId: string, preserveSelection: boolean) {
async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, preserveSelection: boolean) {
const generation = ++databaseLoadGeneration;
const connectionId = targetDraft.connectionId;
databaseOptions.value = [];
if (!connectionId) return;
if (!preserveSelection) {
selectedDatabases.value = [];
allDatabases.value = true;
targetDraft.tableFilterMode = "all";
targetDraft.tablePatterns = [];
tablePatternsInput.value = "";
}
if (!connectionId) {
loadingDatabases.value = false;
return;
}
loadingDatabases.value = true;
try {
await connectionStore.ensureConnected(connectionId);
const config = connectionStore.getConfig(connectionId);
const names = config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name);
if (!databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) return;
databaseOptions.value = names;
if (preserveSelection) {
const selected = new Set(selectedDatabases.value);
selectedDatabases.value = names.filter((database) => selected.has(database));
} else {
selectedDatabases.value = [];
allDatabases.value = true;
draft.value.tableFilterMode = "all";
draft.value.tablePatterns = [];
tablePatternsInput.value = "";
}
} catch (error: any) {
toast(error?.message || String(error), 5000);
if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) toast(error?.message || String(error), 5000);
} finally {
loadingDatabases.value = false;
if (generation === databaseLoadGeneration) loadingDatabases.value = false;
}
}
async function openCreateSchedule() {
oneShotDialogOpen.value = false;
editingScheduleId.value = "";
draft.value = newScheduleDraft();
const nextDraft = newScheduleDraft();
draft.value = nextDraft;
allDatabases.value = true;
selectedDatabases.value = [];
tablePatternsInput.value = "";
scheduleDialogOpen.value = true;
await loadDatabases(draft.value.connectionId, false);
await loadDatabases("schedule", draft.value, false);
}
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
oneShotDialogOpen.value = false;
editingScheduleId.value = schedule.id;
draft.value = { ...schedule, databases: [...schedule.databases], tablePatterns: [...schedule.tablePatterns] };
allDatabases.value = schedule.databases.length === 0;
selectedDatabases.value = [...schedule.databases];
tablePatternsInput.value = schedule.tablePatterns.join(", ");
scheduleDialogOpen.value = true;
await loadDatabases(schedule.connectionId, true);
await loadDatabases("schedule", draft.value, true);
}
async function changeConnection(connectionId: string) {
draft.value.connectionId = connectionId;
await loadDatabases(connectionId, false);
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
targetDraft.connectionId = connectionId;
await loadDatabases(dialog, targetDraft, false);
}
function toggleDatabase(database: string) {
@@ -196,7 +235,7 @@ function toggleDatabase(database: string) {
async function chooseDestination() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
if (typeof selected === "string") draft.value.destinationDirectory = selected;
if (typeof selected === "string") activeDraft.value.destinationDirectory = selected;
}
async function submitSchedule() {
@@ -218,6 +257,47 @@ async function submitSchedule() {
}
}
async function openOneShotBackup() {
scheduleDialogOpen.value = false;
const nextDraft = newBackupConfig();
oneShotDraft.value = nextDraft;
allDatabases.value = true;
selectedDatabases.value = [];
tablePatternsInput.value = "";
oneShotDialogOpen.value = true;
await loadDatabases("one-shot", oneShotDraft.value, false);
}
async function startOneShotBackup() {
if (!canStartOneShot.value) return;
oneShotStarting.value = true;
try {
await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
const run = await runOneShot(
{
...oneShotDraft.value,
databases: allDatabases.value ? [] : [...selectedDatabases.value],
tablePatterns: oneShotDraft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
},
t("databaseBackup.oneShotName"),
);
if (!run) return;
oneShotDialogOpen.value = false;
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
else toast(t("databaseBackup.runFailed", { error: run.error ? translateBackendError(t, run.error) : t("databaseBackup.unknownError") }), 5000);
} catch (error: any) {
toast(translateBackendError(t, error), 5000);
} finally {
oneShotStarting.value = false;
}
}
async function cancelActiveOneShotBackup() {
const run = activeOneShotRun.value;
if (run) await cancelRun(run.id);
}
async function runNow(schedule: DatabaseBackupSchedule) {
try {
const run = await runSchedule(schedule.id, "manual");
@@ -309,16 +389,29 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
</div>
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
<Plus class="mr-2 h-4 w-4" />
{{ t("databaseBackup.addSchedule") }}
</Button>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.oneShotBackup') : t('databaseBackup.noSupportedConnections')" @click="openOneShotBackup">
<Play class="mr-2 h-4 w-4" />
{{ t("databaseBackup.oneShotBackup") }}
</Button>
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
<Plus class="mr-2 h-4 w-4" />
{{ t("databaseBackup.addSchedule") }}
</Button>
</div>
</div>
<div class="overflow-hidden rounded-md border border-border/70">
<div v-if="schedules.length === 0" class="flex min-h-36 flex-col items-center justify-center gap-2 px-4 py-8 text-center text-muted-foreground">
<div v-if="schedules.length === 0" class="flex min-h-44 flex-col items-center justify-center gap-3 px-4 py-8 text-center text-muted-foreground">
<DatabaseBackup class="h-8 w-8 opacity-60" />
<span class="text-sm">{{ t("databaseBackup.noSchedules") }}</span>
<div>
<div class="text-sm font-medium text-foreground">{{ t("databaseBackup.noSchedules") }}</div>
<p class="mt-1 text-sm">{{ t("databaseBackup.noSchedulesHint") }}</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" @click="openOneShotBackup"><Play class="mr-2 h-4 w-4" />{{ t("databaseBackup.oneShotBackup") }}</Button>
<Button size="sm" :disabled="!canCreateSchedule" @click="openCreateSchedule"><Plus class="mr-2 h-4 w-4" />{{ t("databaseBackup.addSchedule") }}</Button>
</div>
</div>
<div v-for="schedule in schedules" :key="schedule.id" class="grid gap-3 border-b border-border/70 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div class="min-w-0">
@@ -367,7 +460,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.source === "one-shot" ? t("databaseBackup.oneShotTrigger") : run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ run.connectionName || connectionName(run.connectionId) }}</span>
@@ -383,7 +476,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="flex items-center justify-end gap-1">
<Loader2 v-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button v-if="activeRunIds.has(run.id) && run.source === 'one-shot'" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(run.id)">
<Square class="h-4 w-4" />
</Button>
<Loader2 v-else-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
<Pencil class="h-4 w-4" />
</Button>
@@ -424,69 +520,25 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogHeader>
<div class="grid gap-5 py-1">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => changeConnection(String(value))">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.destination") }}</Label>
<div class="flex gap-2">
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="chooseDestination">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input v-model="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" />
{{ t("databaseBackup.allDatabases") }}
</label>
</div>
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="toggleDatabase(database)" />
<span class="truncate">{{ database }}</span>
</label>
</div>
</div>
<div class="space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
<div class="space-y-2">
<Label>{{ t("databaseBackup.tableScope") }}</Label>
<Select v-model="draft.tableFilterMode">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
<Input v-model="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" />
</div>
</div>
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
</div>
<DatabaseBackupConfigFields
:draft="draft"
:connections="sqlConnections"
:all-databases="allDatabases"
:selected-databases="selectedDatabases"
:database-options="databaseOptions"
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="(value: boolean) => (allDatabases = value)"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
/>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-2">
@@ -524,16 +576,6 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
<div class="space-y-3">
<Label>{{ t("databaseBackup.contents") }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
</div>
</div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 pt-4">
<div>
<Label>{{ t("databaseBackup.enabled") }}</Label>
@@ -553,6 +595,41 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogContent>
</Dialog>
<Dialog v-model:open="oneShotDialogOpen">
<DialogContent class="dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ t("databaseBackup.oneShotBackup") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.oneShotDescription") }}</p>
</DialogHeader>
<DatabaseBackupConfigFields
:draft="oneShotDraft"
:connections="sqlConnections"
:all-databases="allDatabases"
:selected-databases="selectedDatabases"
:database-options="databaseOptions"
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="(value: boolean) => (allDatabases = value)"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
/>
<DialogFooter>
<Button variant="outline" @click="oneShotDialogOpen = false">{{ oneShotStarting ? t("common.close") : t("common.cancel") }}</Button>
<Button v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun" :title="t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
<Square class="mr-2 h-4 w-4" />
{{ t("databaseBackup.cancel") }}
</Button>
<Button v-else :disabled="!canStartOneShot" @click="startOneShotBackup">
{{ t("databaseBackup.startBackup") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="deleteScheduleDialogOpen">
<DialogContent class="max-w-md">
<DialogHeader
@@ -2,15 +2,18 @@
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import type { DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
import i18n from "../../../i18n";
import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.vue";
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
connections: [] as Array<{ id: string; name: string; db_type: string }>,
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
activeRunIds: new Set<string>(),
activeRuns: [] as DatabaseBackupRun[],
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async () => [{ name: "app" }]),
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
toast: vi.fn(),
saveSchedule: vi.fn(),
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
runSchedule: vi.fn(),
runOneShot: vi.fn(),
cancelRun: vi.fn(),
}));
@@ -33,15 +37,16 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
schedules: { __v_isRef: true, value: mocks.schedules },
runs: { __v_isRef: true, value: [] },
runs: { __v_isRef: true, value: mocks.runs },
activeScheduleIds: new Set<string>(),
activeRunIds: new Set<string>(),
activeRuns: { __v_isRef: true, value: [] },
activeRunIds: mocks.activeRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
runSchedule: mocks.runSchedule,
runOneShot: mocks.runOneShot,
cancelRun: mocks.cancelRun,
}),
}));
@@ -50,6 +55,10 @@ vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(async () => "/backups"),
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
@@ -75,6 +84,58 @@ async function mountSettings() {
await flush();
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function currentDialog(): HTMLElement {
const dialog = document.body.querySelector<HTMLElement>('[data-slot="dialog-content"]');
if (!dialog) throw new Error("Dialog not found");
return dialog;
}
async function selectDialogOption(triggerIndex: number, optionText: string) {
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find((item) => item.textContent?.trim() === optionText);
if (!option) throw new Error(`Select option not found: ${optionText}`);
option.focus();
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flush();
}
async function setTablePattern(pattern: string) {
await selectDialogOption(1, String(i18n.global.t("databaseBackup.includeTables")));
const input = currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`);
if (!input) throw new Error("Table pattern input not found");
input.value = pattern;
input.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
}
async function showDatabaseOptions() {
const label = String(i18n.global.t("databaseBackup.allDatabases"));
const checkbox = Array.from(currentDialog().querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find((item) => item.parentElement?.textContent?.includes(label));
if (!checkbox) throw new Error("All databases checkbox not found");
checkbox.checked = false;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
await flush();
}
function buttonWithText(text: string): HTMLButtonElement {
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button;
}
function addScheduleButton(): HTMLButtonElement {
const label = String(i18n.global.t("databaseBackup.addSchedule"));
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
@@ -127,12 +188,19 @@ afterEach(() => {
document.body.innerHTML = "";
mocks.connections.splice(0);
mocks.schedules.splice(0);
mocks.runs.splice(0);
mocks.activeRuns.splice(0);
mocks.activeRunIds.clear();
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockClear();
mocks.listDatabases.mockReset();
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
mocks.recordDatabaseExportDestination.mockReset();
mocks.recordDatabaseExportDestination.mockResolvedValue(undefined);
mocks.toast.mockClear();
mocks.saveSchedule.mockClear();
mocks.cancelRun.mockClear();
mocks.runOneShot.mockReset();
mocks.runOneShot.mockResolvedValue(null);
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
@@ -153,6 +221,142 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
});
it("opens an independent one-shot dialog without schedule fields", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.oneShotDescription")));
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.connection")));
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.contents")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.scheduleName")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.frequency")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.retention")));
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.enabled")));
});
it("validates one-shot fields and starts without creating a schedule", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.runOneShot.mockResolvedValueOnce({ id: "run-1", status: "success", files: [], scheduleName: "One-time backup" });
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
const startLabel = String(i18n.global.t("databaseBackup.startBackup"));
const startButton = Array.from(dialog?.querySelectorAll("button") ?? []).find((item) => item.textContent?.trim() === startLabel) as HTMLButtonElement | undefined;
expect(startButton?.disabled).toBe(true);
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
expect(startButton?.disabled).toBe(false);
startButton?.click();
await flush();
expect(mocks.schedules).toHaveLength(0);
expect(mocks.saveSchedule).not.toHaveBeenCalled();
expect(mocks.runOneShot).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "mysql-1", destinationDirectory: "/backups", databases: [] }), String(i18n.global.t("databaseBackup.oneShotName")));
});
it("keeps the newer connection database list when an older response finishes last", async () => {
mocks.connections.push({ id: "mysql-a", name: "MySQL A", db_type: "mysql" }, { id: "mysql-b", name: "MySQL B", db_type: "mysql" });
const firstLoad = deferred<Array<{ name: string }>>();
const secondLoad = deferred<Array<{ name: string }>>();
mocks.listDatabases.mockImplementation((connectionId: string) => (connectionId === "mysql-a" ? firstLoad.promise : secondLoad.promise));
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
await selectDialogOption(0, "MySQL B");
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
secondLoad.resolve([{ name: "new_database" }]);
await flush();
await showDatabaseOptions();
expect(currentDialog().textContent).toContain("new_database");
firstLoad.resolve([{ name: "stale_database" }]);
await flush();
expect(currentDialog().textContent).toContain("new_database");
expect(currentDialog().textContent).not.toContain("stale_database");
});
it("does not let a closed one-shot request clear a schedule draft", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
const oneShotLoad = deferred<Array<{ name: string }>>();
const scheduleLoad = deferred<Array<{ name: string }>>();
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise).mockImplementationOnce(() => scheduleLoad.promise);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(1));
const closeOneShot = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.cancel")));
closeOneShot?.click();
await flush();
addScheduleButton().click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
await setTablePattern("schedule_*");
oneShotLoad.resolve([{ name: "one_shot_database" }]);
await flush();
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
scheduleLoad.resolve([{ name: "schedule_database" }]);
await flush();
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
});
it("keeps closing the one-shot dialog separate from cancelling the active run", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
const activeRun = {
id: "one-shot-active",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
progressPercent: 25,
} satisfies DatabaseBackupRun;
mocks.runs.push(activeRun);
mocks.activeRuns.push(activeRun);
mocks.activeRunIds.add(activeRun.id);
const pendingRun = deferred<DatabaseBackupRun>();
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
const startButton = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("databaseBackup.startBackup")));
startButton?.click();
await flush();
const runningDialog = currentDialog();
const closeButton = Array.from(runningDialog.querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.close")));
const cancelButton = Array.from(runningDialog.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.title === String(i18n.global.t("databaseBackup.cancel")));
expect(closeButton).toBeTruthy();
expect(cancelButton?.disabled).toBe(false);
closeButton?.click();
await flush();
expect(mocks.cancelRun).not.toHaveBeenCalled();
buttonWithTitle(String(i18n.global.t("databaseBackup.cancel"))).click();
await flush();
expect(mocks.cancelRun).toHaveBeenCalledWith(activeRun.id);
pendingRun.resolve({ ...activeRun, status: "cancelled", completedAt: "2026-08-18T00:01:00.000Z" });
await flush();
});
it("disables create schedule when there are no supported backup connections", async () => {
mocks.connections.push({ id: "oracle-1", name: "Oracle", db_type: "oracle" });
await mountSettings();
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
listDatabases: vi.fn(async () => [{ name: "app" }]),
beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
rollbackSnapshot: vi.fn(async () => {}),
deleteFiles: vi.fn(async () => 0),
cancelExport: vi.fn(async () => {}),
exportDatabase: vi.fn(),
runDatabaseExport: vi.fn(),
addTask: vi.fn(),
registerCancel: vi.fn(),
unregisterCancel: vi.fn(),
updateTask: vi.fn(),
nextId: 0,
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: mocks.listDatabases,
beginDatabaseBackupSnapshot: mocks.beginSnapshot,
rollbackManualTransaction: mocks.rollbackSnapshot,
deleteDatabaseBackupFiles: mocks.deleteFiles,
cancelDatabaseExport: mocks.cancelExport,
}));
vi.mock("@/lib/export/databaseExport", () => ({
buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
generateDatabaseExportId: () => `run-${++mocks.nextId}`,
runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: () => mocks.connection,
ensureConnected: vi.fn(async () => {}),
initFromDisk: vi.fn(async () => {}),
}),
}));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
registerTaskCancelHandler: mocks.registerCancel,
unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
}),
}));
import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
const config: DatabaseBackupExecutionConfig = {
connectionId: "mysql-1",
databases: ["app"],
tableFilterMode: "all",
tablePatterns: [],
destinationDirectory: "/backups",
includeStructure: true,
includeData: true,
includeObjects: true,
dropTableIfExists: false,
};
describe("useScheduledDatabaseBackups one-shot execution", () => {
it("executes one-shot backups through the shared exporter without a schedule", async () => {
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
});
const backup = useScheduledDatabaseBackups();
const scheduleCountBefore = backup.schedules.value.length;
const run = await backup.runOneShot(config, "One-time backup");
expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
expect(run).not.toHaveProperty("nextRunAt");
expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
});
it("prevents a second one-shot while one is already running", async () => {
const backup = useScheduledDatabaseBackups();
const activeRun = {
id: "active-one-shot",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "running",
startedAt: new Date().toISOString(),
files: [],
} satisfies DatabaseBackupRun;
backup.runs.value.push(activeRun);
backup.activeRunIds.add(activeRun.id);
const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
try {
expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
} finally {
backup.activeRunIds.delete(activeRun.id);
backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
}
});
it("cancels an active one-shot through the shared export cancellation path", async () => {
mocks.cancelExport.mockClear();
mocks.deleteFiles.mockClear();
let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
mocks.runDatabaseExport.mockImplementationOnce(
(_request: unknown, onProgress: (progress: unknown) => void) =>
new Promise((resolve) => {
resolveExport = (progress) => {
onProgress(progress);
resolve(progress);
};
}),
);
const backup = useScheduledDatabaseBackups();
const pendingRun = backup.runOneShot(config, "One-time backup");
await new Promise((resolve) => setTimeout(resolve, 0));
const activeRunId = [...backup.activeRunIds][0];
expect(activeRunId).toBeTruthy();
await backup.cancelRun(activeRunId!);
expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
const finishedRun = await pendingRun;
expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)]);
expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
});
});
@@ -22,10 +22,13 @@ import {
resolveScheduledDatabaseBackupTableScope,
resolveScheduledDatabaseBackupTargets,
supportsScheduledDatabaseBackup,
toDatabaseBackupExecutionConfig,
writeDatabaseBackupRuns,
writeDatabaseBackupSchedules,
type DatabaseBackupExecutionConfig,
type DatabaseBackupFile,
type DatabaseBackupRun,
type DatabaseBackupRunSource,
type DatabaseBackupRunStatus,
type DatabaseBackupRunTrigger,
type DatabaseBackupSchedule,
@@ -72,6 +75,8 @@ function updateRun(runId: string, patch: Partial<DatabaseBackupRun>, persist = t
return updated;
}
type FinishedDatabaseBackupRun = Omit<DatabaseBackupRun, "status"> & { status: Exclude<DatabaseBackupRunStatus, "running"> };
function refreshFromStorage() {
schedules.value = readDatabaseBackupSchedules();
runs.value = readDatabaseBackupRuns();
@@ -158,54 +163,52 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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);
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
const backupName = request.displayName;
const connection = connectionStore.getConfig(config.connectionId);
const startedAt = new Date();
const runId = generateDatabaseExportId();
const run: DatabaseBackupRun = {
id: runId,
scheduleId: schedule.id,
scheduleName: schedule.name,
connectionId: schedule.connectionId,
scheduleId: request.scheduleId,
scheduleName: backupName,
connectionId: config.connectionId,
connectionName: connection?.name ?? "",
trigger,
trigger: request.trigger,
source: request.source,
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");
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
registerTaskCancelHandler(runId, () => cancelRun(runId));
let finalStatus: Exclude<DatabaseBackupRunStatus, "running"> = "success";
let finalError = "";
let finishedRun: DatabaseBackupRun | null = null;
let finishedRun: FinishedDatabaseBackupRun | null = null;
let lastProgressPercent = 0;
const generatedPaths: string[] = [];
await databaseBackupConnectionQueue.run(schedule.connectionId, async () => {
await databaseBackupConnectionQueue.run(config.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);
await connectionStore.ensureConnected(config.connectionId);
const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.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") {
if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
try {
const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
const result = await api.executeQuery(config.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);
@@ -219,7 +222,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
finalStatus = "cancelled";
break;
}
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(schedule.connectionId, database) } : undefined;
const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
const databasePlan = buildAllDatabaseExportPlan({
databases: [database],
schemaAware: connection.db_type === "postgres",
@@ -228,18 +231,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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") {
if (config.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);
const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.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);
const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database);
let snapshotCompleted = false;
try {
for (const [planIndex, item] of scopedDatabasePlan.entries()) {
@@ -250,21 +253,21 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
exportIndex += 1;
const childExportId = `${runId}-${exportIndex}`;
activeExportIds.set(runId, childExportId);
const filePath = databaseBackupFilePath(schedule.destinationDirectory, schedule.name, item.fileStem, startedAt, runId);
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId);
generatedPaths.push(filePath);
const terminal = await runDatabaseExportUntilTerminal(
{
exportId: childExportId,
connectionId: schedule.connectionId,
connectionId: config.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,
includeStructure: config.includeStructure,
includeData: config.includeData,
includeObjects: config.includeObjects,
dropTableIfExists: config.dropTableIfExists,
failOnError: true,
snapshotSessionId: snapshot.sessionId,
batchSize: 1000,
@@ -322,8 +325,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
});
updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
if (schedule.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`);
if (config.tableFilterMode !== "all" && includedTableCount === 0) {
throw new Error(`No tables matched the configured ${config.tableFilterMode} backup rules.`);
}
} catch (error: any) {
finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
@@ -342,20 +345,20 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
const completedAt = new Date();
activeExportIds.delete(runId);
cancellationRequested.delete(runId);
activeScheduleIds.delete(schedule.id);
activeRunIds.delete(runId);
unregisterTaskCancelHandler(runId);
finishedRun = updateRun(runId, {
const updatedRun = updateRun(runId, {
status: finalStatus,
completedAt: completedAt.toISOString(),
files: [...run.files],
progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
error: finalError || undefined,
});
finishedRun = updatedRun && updatedRun.status !== "running" ? (updatedRun as FinishedDatabaseBackupRun) : null;
updateDatabaseExportTask(runId, {
exportId: runId,
currentObject: schedule.name,
currentObject: backupName,
objectIndex: finalStatus === "success" ? run.files.length : 0,
totalObjects: run.files.length,
rowsExported: 0,
@@ -365,24 +368,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
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,
scheduleId: request.scheduleId,
runId,
files: run.files.length,
error: finalError || undefined,
@@ -392,6 +379,44 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
return finishedRun;
}
async function finalizeScheduledRun(scheduleId: string, trigger: DatabaseBackupRunTrigger, run: FinishedDatabaseBackupRun) {
const schedule = schedules.value.find((item) => item.id === scheduleId);
if (!schedule) return;
const completedAt = run.completedAt ? new Date(run.completedAt) : new Date();
schedules.value = schedules.value.map((item) =>
item.id === scheduleId
? {
...item,
lastRunAt: completedAt.toISOString(),
lastRunStatus: run.status,
nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
}
: item,
);
persistSchedules();
if (run.status === "success") await pruneScheduleRuns(schedule);
}
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;
activeScheduleIds.add(scheduleId);
try {
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), { source: "scheduled", trigger, scheduleId, displayName: schedule.name });
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
return run;
} finally {
activeScheduleIds.delete(scheduleId);
}
}
async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup"): Promise<FinishedDatabaseBackupRun | null> {
if (!isTauriRuntime()) throw new Error("One-shot database backups are only available in the desktop app.");
if (activeRuns.value.some((run) => run.source === "one-shot")) return null;
return runBackup(config, { source: "one-shot", trigger: "manual", displayName });
}
async function processDueSchedules() {
if (processingDueSchedules) return;
processingDueSchedules = true;
@@ -453,6 +478,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
deleteRun,
renameRun,
runSchedule,
runOneShot,
cancelRun,
processDueSchedules,
};
+6
View File
@@ -6567,10 +6567,15 @@ export default {
schedules: "Backup schedules",
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
addSchedule: "Add schedule",
oneShotBackup: "Back up now",
oneShotDescription: "Run a one-time database backup. It will appear in backup history when complete.",
startBackup: "Start backup",
noSupportedConnections: "Create a MySQL or PostgreSQL connection before adding a backup schedule.",
editSchedule: "Edit schedule",
defaultScheduleName: "Database backup",
oneShotName: "One-time backup",
noSchedules: "No backup schedules",
noSchedulesHint: "Create a recurring backup schedule, or run a one-time backup now.",
scheduleName: "Schedule name",
connection: "Connection",
missingConnection: "Missing connection",
@@ -6622,6 +6627,7 @@ export default {
showFiles: "Show backup files",
scheduledTrigger: "Scheduled",
manualTrigger: "Manual",
oneShotTrigger: "One-time",
fileCount: "{count} files",
progress: "Backup progress",
renameBackup: "Rename backup",
+6
View File
@@ -7710,6 +7710,12 @@ export default withEnglishFallback({
friday: "Viernes",
saturday: "Sábado",
},
oneShotBackup: "Copia de seguridad inmediata",
oneShotDescription: "Ejecutar una copia de seguridad de la base de datos inmediatamente; después de completarse, aparecerá en el historial de copias de seguridad.",
startBackup: "Iniciar copia de seguridad",
oneShotName: "Copia de seguridad única",
noSchedulesHint: "Puede crear un plan de copia de seguridad programado o ejecutar una copia de seguridad inmediata.",
oneShotTrigger: "Copia de seguridad única",
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
+6
View File
@@ -7710,6 +7710,12 @@ export default withEnglishFallback({
friday: "Venerdì",
saturday: "Sabato",
},
oneShotBackup: "Backup immediato",
oneShotDescription: "Esegui immediatamente un backup del database; al termine apparirà nella cronologia dei backup.",
startBackup: "Avvia backup",
oneShotName: "Backup una tantum",
noSchedulesHint: "Puoi creare pianificazioni di backup periodiche o eseguire subito un backup.",
oneShotTrigger: "Backup una tantum",
},
mqExchanges: {
searchPlaceholder: "Cerca exchange",
+6
View File
@@ -7763,6 +7763,12 @@ export default withEnglishFallback({
friday: "金曜日",
saturday: "土曜日",
},
oneShotBackup: "今すぐバックアップ",
oneShotDescription: "データベースのバックアップを今すぐ実行します。完了すると、バックアップ履歴に表示されます。",
startBackup: "バックアップを開始",
oneShotName: "ワンタイムバックアップ",
noSchedulesHint: "定期的なバックアップスケジュールを作成するか、今すぐバックアップを実行できます。",
oneShotTrigger: "ワンタイムバックアップ",
},
mqExchanges: {
searchPlaceholder: "エクスチェンジを検索",
+6
View File
@@ -7712,6 +7712,12 @@ export default withEnglishFallback({
friday: "Sexta-feira",
saturday: "Sábado",
},
oneShotBackup: "Backup imediato",
oneShotDescription: "Execute um backup do banco de dados imediatamente; quando concluído, ele aparecerá no histórico de backups.",
startBackup: "Iniciar backup",
oneShotName: "Backup único",
noSchedulesHint: "Você pode criar um agendamento de backup recorrente ou executar um backup imediatamente.",
oneShotTrigger: "Backup único",
},
mqExchanges: {
searchPlaceholder: "Buscar exchanges",
+6
View File
@@ -6552,10 +6552,15 @@ export default withEnglishFallback({
schedules: "备份计划",
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
addSchedule: "新建计划",
oneShotBackup: "立即备份",
oneShotDescription: "立即执行一次数据库备份,完成后将出现在备份历史中。",
startBackup: "开始备份",
noSupportedConnections: "请先创建 MySQL 或 PostgreSQL 连接,再新建备份计划。",
editSchedule: "编辑计划",
defaultScheduleName: "数据库备份",
oneShotName: "一次性备份",
noSchedules: "暂无备份计划",
noSchedulesHint: "你可以创建定时备份计划,或立即执行一次备份。",
scheduleName: "计划名称",
connection: "连接",
missingConnection: "连接已不存在",
@@ -6607,6 +6612,7 @@ export default withEnglishFallback({
showFiles: "显示备份文件",
scheduledTrigger: "定时执行",
manualTrigger: "手动执行",
oneShotTrigger: "一次性备份",
fileCount: "{count} 个文件",
progress: "备份进度",
renameBackup: "重命名备份",
+6
View File
@@ -7703,6 +7703,12 @@ export default withEnglishFallback({
friday: "星期五",
saturday: "星期六",
},
oneShotBackup: "立即備份",
oneShotDescription: "立即執行一次資料庫備份,完成後將出現在備份歷史中。",
startBackup: "開始備份",
oneShotName: "一次性備份",
noSchedulesHint: "你可以建立定時備份計劃,或立即執行一次備份。",
oneShotTrigger: "一次性備份",
},
mqExchanges: {
searchPlaceholder: "搜尋交換機",
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { databaseBackupRunsToPrune, normalizeDatabaseBackupRun, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
function run(overrides: Record<string, unknown> = {}) {
return {
id: "run-1",
scheduleName: "One-time backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "one-shot",
status: "success",
startedAt,
files: [],
...overrides,
};
}
describe("database backup run persistence", () => {
it("accepts a one-shot run without a schedule id", () => {
const normalized = normalizeDatabaseBackupRun(run());
expect(normalized).toEqual(
expect.objectContaining({
id: "run-1",
scheduleId: undefined,
source: "one-shot",
trigger: "manual",
}),
);
});
it("keeps legacy runs compatible and treats them as scheduled history", () => {
const normalized = normalizeDatabaseBackupRun(run({ scheduleId: "schedule-1", source: undefined, scheduleName: "Nightly backup" }));
expect(normalized).toEqual(expect.objectContaining({ scheduleId: "schedule-1", source: "scheduled", scheduleName: "Nightly backup" }));
});
it("does not include one-shot runs in schedule retention pruning", () => {
const scheduledRuns = [run({ id: "scheduled-old", scheduleId: "schedule-1", source: "scheduled", startedAt: "2026-08-10T00:00:00.000Z" }), run({ id: "scheduled-new", scheduleId: "schedule-1", source: "scheduled", startedAt: "2026-08-11T00:00:00.000Z" })];
const oneShotRun = run({ id: "one-shot", scheduleId: undefined, source: "one-shot", startedAt: "2026-08-12T00:00:00.000Z" });
const normalizedRuns = [...scheduledRuns, oneShotRun].map((value) => normalizeDatabaseBackupRun(value)!).filter((value): value is DatabaseBackupRun => !!value);
expect(databaseBackupRunsToPrune(normalizedRuns, "schedule-1", 1).map((value) => value.id)).toEqual(["scheduled-old"]);
});
});
@@ -11,6 +11,7 @@ export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled";
export type DatabaseBackupRunTrigger = "manual" | "scheduled";
export type DatabaseBackupRunSource = "scheduled" | "one-shot";
export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude";
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
@@ -106,23 +107,26 @@ export function resolveScheduledDatabaseBackupTableScope(mode: DatabaseBackupTab
};
}
export interface DatabaseBackupSchedule {
id: string;
name: string;
enabled: boolean;
export interface DatabaseBackupExecutionConfig {
connectionId: string;
databases: string[];
tableFilterMode: DatabaseBackupTableFilterMode;
tablePatterns: string[];
destinationDirectory: string;
frequency: DatabaseBackupFrequency;
intervalHours: number;
timeOfDay: string;
weekday: number;
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;
dropTableIfExists: boolean;
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
id: string;
name: string;
enabled: boolean;
frequency: DatabaseBackupFrequency;
intervalHours: number;
timeOfDay: string;
weekday: number;
retentionCount: number;
createdAt: string;
updatedAt: string;
@@ -131,6 +135,20 @@ export interface DatabaseBackupSchedule {
lastRunStatus?: Exclude<DatabaseBackupRunStatus, "running">;
}
export function toDatabaseBackupExecutionConfig(schedule: DatabaseBackupSchedule): DatabaseBackupExecutionConfig {
return {
connectionId: schedule.connectionId,
databases: [...schedule.databases],
tableFilterMode: schedule.tableFilterMode,
tablePatterns: [...schedule.tablePatterns],
destinationDirectory: schedule.destinationDirectory,
includeStructure: schedule.includeStructure,
includeData: schedule.includeData,
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
};
}
export interface DatabaseBackupFile {
database: string;
schema: string;
@@ -140,12 +158,13 @@ export interface DatabaseBackupFile {
export interface DatabaseBackupRun {
id: string;
scheduleId: string;
scheduleId?: string;
scheduleName: string;
displayName?: string;
connectionId: string;
connectionName: string;
trigger: DatabaseBackupRunTrigger;
source: DatabaseBackupRunSource;
status: DatabaseBackupRunStatus;
startedAt: string;
completedAt?: string;
@@ -278,9 +297,9 @@ function normalizeDatabaseBackupFile(value: unknown): DatabaseBackupFile | null
export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun | null {
const input = recordValue(value);
const id = stringValue(input.id).trim();
const scheduleId = stringValue(input.scheduleId).trim();
const scheduleId = stringValue(input.scheduleId).trim() || undefined;
const startedAt = validIsoDate(input.startedAt, "");
if (!id || !scheduleId || !startedAt) return null;
if (!id || !startedAt) return null;
const status: DatabaseBackupRunStatus = input.status === "success" || input.status === "failed" || input.status === "cancelled" ? input.status : "failed";
return {
id,
@@ -290,6 +309,7 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun |
connectionId: stringValue(input.connectionId).trim(),
connectionName: stringValue(input.connectionName).trim(),
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
source: input.source === "one-shot" ? "one-shot" : "scheduled",
status,
startedAt,
completedAt: typeof input.completedAt === "string" && Number.isFinite(Date.parse(input.completedAt)) ? input.completedAt : undefined,