feat(backup): support table filters and MySQL compatibility
This commit is contained in:
parent
09c3f7f44c
commit
7abac0959f
|
|
@ -13,7 +13,7 @@ import * as api from "@/lib/backend/api";
|
|||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { nextDatabaseBackupRunAt, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
|
@ -32,6 +32,7 @@ const saving = ref(false);
|
|||
const databaseOptions = ref<string[]>([]);
|
||||
const allDatabases = ref(true);
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
const tablePatternsInput = ref("");
|
||||
const expandedRunIds = reactive(new Set<string>());
|
||||
|
||||
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
|
||||
|
|
@ -54,6 +55,8 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
|
|||
enabled: true,
|
||||
connectionId,
|
||||
databases: [],
|
||||
tableFilterMode: "all",
|
||||
tablePatterns: [],
|
||||
destinationDirectory: "",
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
|
|
@ -76,7 +79,8 @@ const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
|
|||
const canSave = computed(() => {
|
||||
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
|
||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && !saving.value && !loadingDatabases.value;
|
||||
const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
|
||||
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()));
|
||||
|
||||
|
|
@ -104,6 +108,12 @@ function databaseScopeLabel(schedule: DatabaseBackupSchedule): string {
|
|||
return t("databaseBackup.databaseCount", { count: schedule.databases.length });
|
||||
}
|
||||
|
||||
function tableScopeLabel(schedule: DatabaseBackupSchedule): string {
|
||||
if (schedule.tableFilterMode === "include") return t("databaseBackup.includedTablePatterns", { count: schedule.tablePatterns.length });
|
||||
if (schedule.tableFilterMode === "exclude") return t("databaseBackup.excludedTablePatterns", { count: schedule.tablePatterns.length });
|
||||
return "";
|
||||
}
|
||||
|
||||
function runStatusLabel(status: DatabaseBackupRun["status"]): string {
|
||||
return t(`databaseBackup.status.${status}`);
|
||||
}
|
||||
|
|
@ -130,6 +140,9 @@ async function loadDatabases(connectionId: string, preserveSelection: boolean) {
|
|||
if (!preserveSelection) {
|
||||
selectedDatabases.value = [];
|
||||
allDatabases.value = true;
|
||||
draft.value.tableFilterMode = "all";
|
||||
draft.value.tablePatterns = [];
|
||||
tablePatternsInput.value = "";
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
|
|
@ -143,15 +156,17 @@ async function openCreateSchedule() {
|
|||
draft.value = newScheduleDraft();
|
||||
allDatabases.value = true;
|
||||
selectedDatabases.value = [];
|
||||
tablePatternsInput.value = "";
|
||||
scheduleDialogOpen.value = true;
|
||||
await loadDatabases(draft.value.connectionId, false);
|
||||
}
|
||||
|
||||
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
||||
editingScheduleId.value = schedule.id;
|
||||
draft.value = { ...schedule, databases: [...schedule.databases] };
|
||||
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);
|
||||
}
|
||||
|
|
@ -181,6 +196,7 @@ async function submitSchedule() {
|
|||
saveSchedule({
|
||||
...draft.value,
|
||||
databases: allDatabases.value ? [] : [...selectedDatabases.value],
|
||||
tablePatterns: draft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
|
||||
});
|
||||
scheduleDialogOpen.value = false;
|
||||
toast(t(editingScheduleId.value ? "databaseBackup.scheduleUpdated" : "databaseBackup.scheduleCreated"), 2500);
|
||||
|
|
@ -286,6 +302,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{{ frequencyLabel(schedule) }}</span>
|
||||
<span>{{ databaseScopeLabel(schedule) }}</span>
|
||||
<span v-if="schedule.tableFilterMode !== 'all'">{{ tableScopeLabel(schedule) }}</span>
|
||||
<span>{{ t("databaseBackup.nextRun", { time: formatDate(schedule.nextRunAt) }) }}</span>
|
||||
<span>{{ t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}</span>
|
||||
</div>
|
||||
|
|
@ -413,6 +430,27 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||
</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>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.frequency") }}</Label>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ import {
|
|||
databaseBackupFilePath,
|
||||
databaseBackupRunsToPrune,
|
||||
databaseBackupScheduleIsDue,
|
||||
databaseBackupTableNamesAreCaseSensitive,
|
||||
nextDatabaseBackupRunAt,
|
||||
normalizeDatabaseBackupSchedule,
|
||||
readDatabaseBackupRuns,
|
||||
readDatabaseBackupSchedules,
|
||||
resolveScheduledDatabaseBackupTableScope,
|
||||
resolveScheduledDatabaseBackupTargets,
|
||||
supportsScheduledDatabaseBackup,
|
||||
writeDatabaseBackupRuns,
|
||||
|
|
@ -178,11 +180,22 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
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);
|
||||
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 totalExports = selectedDatabases.length;
|
||||
let totalExports = 0;
|
||||
let includedTableCount = 0;
|
||||
for (const database of selectedDatabases) {
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
|
|
@ -197,9 +210,21 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
schemasByDatabase: { [database]: snapshot.schemas },
|
||||
});
|
||||
if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
|
||||
totalExports += databasePlan.length - 1;
|
||||
|
||||
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 });
|
||||
}
|
||||
totalExports += scopedDatabasePlan.length;
|
||||
|
||||
for (const item of scopedDatabasePlan) {
|
||||
if (cancellationRequested.has(runId)) {
|
||||
finalStatus = "cancelled";
|
||||
break;
|
||||
|
|
@ -216,6 +241,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
database: item.database,
|
||||
schema: item.schema,
|
||||
filePath,
|
||||
selectedTables: item.selectedTables,
|
||||
excludedTables: item.excludedTables,
|
||||
includeStructure: schedule.includeStructure,
|
||||
includeData: schedule.includeData,
|
||||
includeObjects: schedule.includeObjects,
|
||||
|
|
@ -257,6 +284,9 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||
}
|
||||
if (finalStatus !== "success") break;
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -4096,6 +4096,15 @@ export default {
|
|||
databases: "Databases",
|
||||
allDatabases: "All databases",
|
||||
databaseCount: "{count} databases",
|
||||
tableScope: "Table scope",
|
||||
allTables: "All tables and views",
|
||||
includeTables: "Include matches only",
|
||||
excludeTables: "Exclude matches",
|
||||
tablePatterns: "Table name rules",
|
||||
tablePatternsPlaceholder: "orders, audit_*, public.users",
|
||||
tablePatternsHint: "Separate rules with commas. * and ? wildcards and schema.table are supported.",
|
||||
includedTablePatterns: "{count} include rules",
|
||||
excludedTablePatterns: "{count} exclude rules",
|
||||
frequency: "Frequency",
|
||||
frequencyHourly: "Every few hours",
|
||||
frequencyDaily: "Daily",
|
||||
|
|
|
|||
|
|
@ -4648,6 +4648,15 @@ export default withEnglishFallback({
|
|||
databases: "Bases de datos",
|
||||
allDatabases: "Todas las bases de datos",
|
||||
databaseCount: "{count} bases de datos",
|
||||
tableScope: "Ámbito de tablas",
|
||||
allTables: "Todas las tablas y vistas",
|
||||
includeTables: "Incluir solo coincidencias",
|
||||
excludeTables: "Excluir coincidencias",
|
||||
tablePatterns: "Reglas de nombres de tabla",
|
||||
tablePatternsPlaceholder: "orders, audit_*, public.users",
|
||||
tablePatternsHint: "Separe las reglas con comas. Se admiten los comodines * y ? y schema.table.",
|
||||
includedTablePatterns: "{count} reglas de inclusión",
|
||||
excludedTablePatterns: "{count} reglas de exclusión",
|
||||
frequency: "Frecuencia de ejecución",
|
||||
frequencyHourly: "Cada hora",
|
||||
frequencyDaily: "Diariamente",
|
||||
|
|
|
|||
|
|
@ -4646,6 +4646,15 @@ export default withEnglishFallback({
|
|||
databases: "Database",
|
||||
allDatabases: "Tutti i database",
|
||||
databaseCount: "{count} database",
|
||||
tableScope: "Ambito tabelle",
|
||||
allTables: "Tutte le tabelle e viste",
|
||||
includeTables: "Includi solo corrispondenze",
|
||||
excludeTables: "Escludi corrispondenze",
|
||||
tablePatterns: "Regole nomi tabella",
|
||||
tablePatternsPlaceholder: "orders, audit_*, public.users",
|
||||
tablePatternsHint: "Separa le regole con virgole. Sono supportati i caratteri jolly * e ? e schema.table.",
|
||||
includedTablePatterns: "{count} regole di inclusione",
|
||||
excludedTablePatterns: "{count} regole di esclusione",
|
||||
frequency: "Frequenza",
|
||||
frequencyHourly: "Ogni ora",
|
||||
frequencyDaily: "Ogni giorno",
|
||||
|
|
|
|||
|
|
@ -4647,6 +4647,15 @@ export default withEnglishFallback({
|
|||
databases: "データベース",
|
||||
allDatabases: "すべてのデータベース",
|
||||
databaseCount: "{count} 個のデータベース",
|
||||
tableScope: "テーブル範囲",
|
||||
allTables: "すべてのテーブルとビュー",
|
||||
includeTables: "一致項目のみ含める",
|
||||
excludeTables: "一致項目を除外",
|
||||
tablePatterns: "テーブル名ルール",
|
||||
tablePatternsPlaceholder: "orders, audit_*, public.users",
|
||||
tablePatternsHint: "ルールはカンマで区切ります。*、? ワイルドカードと schema.table を使用できます。",
|
||||
includedTablePatterns: "包含ルール {count} 件",
|
||||
excludedTablePatterns: "除外ルール {count} 件",
|
||||
frequency: "実行頻度",
|
||||
frequencyHourly: "毎時",
|
||||
frequencyDaily: "毎日",
|
||||
|
|
|
|||
|
|
@ -4648,6 +4648,15 @@ export default withEnglishFallback({
|
|||
databases: "Bancos de dados",
|
||||
allDatabases: "Todos os bancos de dados",
|
||||
databaseCount: "{count} bancos de dados",
|
||||
tableScope: "Escopo de tabelas",
|
||||
allTables: "Todas as tabelas e views",
|
||||
includeTables: "Incluir apenas correspondências",
|
||||
excludeTables: "Excluir correspondências",
|
||||
tablePatterns: "Regras de nomes de tabela",
|
||||
tablePatternsPlaceholder: "orders, audit_*, public.users",
|
||||
tablePatternsHint: "Separe as regras com vírgulas. Há suporte a curingas * e ? e schema.table.",
|
||||
includedTablePatterns: "{count} regras de inclusão",
|
||||
excludedTablePatterns: "{count} regras de exclusão",
|
||||
frequency: "Frequência",
|
||||
frequencyHourly: "A cada hora",
|
||||
frequencyDaily: "Diariamente",
|
||||
|
|
|
|||
|
|
@ -4066,6 +4066,15 @@ export default withEnglishFallback({
|
|||
databases: "数据库",
|
||||
allDatabases: "全部数据库",
|
||||
databaseCount: "{count} 个数据库",
|
||||
tableScope: "表范围",
|
||||
allTables: "全部表和视图",
|
||||
includeTables: "仅包含匹配项",
|
||||
excludeTables: "排除匹配项",
|
||||
tablePatterns: "表名规则",
|
||||
tablePatternsPlaceholder: "例如:orders, audit_*, public.users",
|
||||
tablePatternsHint: "用逗号分隔规则,支持 *、? 通配符和 schema.table。",
|
||||
includedTablePatterns: "包含 {count} 条表规则",
|
||||
excludedTablePatterns: "排除 {count} 条表规则",
|
||||
frequency: "执行频率",
|
||||
frequencyHourly: "按小时",
|
||||
frequencyDaily: "每天",
|
||||
|
|
|
|||
|
|
@ -4648,6 +4648,15 @@ export default withEnglishFallback({
|
|||
databases: "資料庫",
|
||||
allDatabases: "全部資料庫",
|
||||
databaseCount: "{count} 個資料庫",
|
||||
tableScope: "資料表範圍",
|
||||
allTables: "全部資料表和檢視表",
|
||||
includeTables: "僅包含符合項目",
|
||||
excludeTables: "排除符合項目",
|
||||
tablePatterns: "資料表名稱規則",
|
||||
tablePatternsPlaceholder: "例如:orders, audit_*, public.users",
|
||||
tablePatternsHint: "請用逗號分隔規則,支援 *、? 萬用字元和 schema.table。",
|
||||
includedTablePatterns: "包含 {count} 條資料表規則",
|
||||
excludedTablePatterns: "排除 {count} 條資料表規則",
|
||||
frequency: "執行頻率",
|
||||
frequencyHourly: "每小時",
|
||||
frequencyDaily: "每天",
|
||||
|
|
|
|||
|
|
@ -2291,6 +2291,7 @@ export interface DatabaseExportRequest {
|
|||
schema: string;
|
||||
filePath: string;
|
||||
selectedTables?: string[];
|
||||
excludedTables?: string[];
|
||||
includeStructure: boolean;
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { isSystemDatabaseName } from "@/lib/database/visibleDatabases";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export const DATABASE_BACKUP_SCHEDULES_STORAGE_KEY = "dbx-database-backup-schedules";
|
||||
export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
|
||||
|
|
@ -8,6 +10,7 @@ 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";
|
||||
export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude";
|
||||
|
||||
const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]);
|
||||
|
||||
|
|
@ -15,9 +18,9 @@ export function supportsScheduledDatabaseBackup(databaseType: string | undefined
|
|||
return !!databaseType && CONSISTENT_BACKUP_DATABASE_TYPES.has(databaseType);
|
||||
}
|
||||
|
||||
export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[]): string[] {
|
||||
export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[], databaseType?: DatabaseType): string[] {
|
||||
const available = [...new Set(availableDatabases.map((database) => database.trim()).filter(Boolean))];
|
||||
if (configuredDatabases.length === 0) return available;
|
||||
if (configuredDatabases.length === 0) return available.filter((database) => !isSystemDatabaseName(databaseType, database));
|
||||
|
||||
const missing = configuredDatabases.filter((database) => !available.includes(database));
|
||||
if (missing.length > 0) {
|
||||
|
|
@ -26,12 +29,64 @@ export function resolveScheduledDatabaseBackupTargets(configuredDatabases: reado
|
|||
return [...configuredDatabases];
|
||||
}
|
||||
|
||||
export interface DatabaseBackupTableScope {
|
||||
includedTables: string[];
|
||||
selectedTables?: string[];
|
||||
excludedTables?: string[];
|
||||
}
|
||||
|
||||
export function normalizeDatabaseBackupTablePatterns(value: unknown): string[] {
|
||||
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,;\n]+/) : [];
|
||||
return [...new Set(values.map((pattern) => stringValue(pattern).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function tablePatternRegex(pattern: string, caseSensitive: boolean): RegExp {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${escaped}$`, caseSensitive ? "u" : "iu");
|
||||
}
|
||||
|
||||
export function databaseBackupTableMatchesPattern(table: string, patterns: readonly string[], database = "", schema = "", caseSensitive = true): boolean {
|
||||
const candidates = [table];
|
||||
if (schema) candidates.push(`${schema}.${table}`);
|
||||
if (database && schema && database !== schema) candidates.push(`${database}.${schema}.${table}`);
|
||||
return patterns.some((pattern) => {
|
||||
const matcher = tablePatternRegex(pattern, caseSensitive);
|
||||
return candidates.some((candidate) => matcher.test(candidate));
|
||||
});
|
||||
}
|
||||
|
||||
export function databaseBackupTableNamesAreCaseSensitive(databaseType: DatabaseType | undefined, mysqlLowerCaseTableNames: unknown): boolean {
|
||||
if (databaseType !== "mysql") return true;
|
||||
const value = typeof mysqlLowerCaseTableNames === "number" ? mysqlLowerCaseTableNames : typeof mysqlLowerCaseTableNames === "string" ? Number(mysqlLowerCaseTableNames.trim()) : Number.NaN;
|
||||
return value !== 1 && value !== 2;
|
||||
}
|
||||
|
||||
export function resolveScheduledDatabaseBackupTableScope(mode: DatabaseBackupTableFilterMode, patterns: readonly string[], availableTables: readonly string[], database = "", schema = "", caseSensitive = true): DatabaseBackupTableScope {
|
||||
const available = [...new Set(availableTables.map((table) => table.trim()).filter(Boolean))];
|
||||
if (mode === "all") return { includedTables: available };
|
||||
|
||||
const normalizedPatterns = normalizeDatabaseBackupTablePatterns(patterns);
|
||||
const matching = available.filter((table) => databaseBackupTableMatchesPattern(table, normalizedPatterns, database, schema, caseSensitive));
|
||||
if (mode === "include") return { includedTables: matching, selectedTables: matching };
|
||||
|
||||
const matchingSet = new Set(matching);
|
||||
return {
|
||||
includedTables: available.filter((table) => !matchingSet.has(table)),
|
||||
excludedTables: matching,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DatabaseBackupSchedule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
connectionId: string;
|
||||
databases: string[];
|
||||
tableFilterMode: DatabaseBackupTableFilterMode;
|
||||
tablePatterns: string[];
|
||||
destinationDirectory: string;
|
||||
frequency: DatabaseBackupFrequency;
|
||||
intervalHours: number;
|
||||
|
|
@ -119,6 +174,8 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
|
|||
enabled: booleanValue(input.enabled, true),
|
||||
connectionId,
|
||||
databases: Array.isArray(input.databases) ? [...new Set(input.databases.map((database) => stringValue(database).trim()).filter(Boolean))] : [],
|
||||
tableFilterMode: input.tableFilterMode === "include" || input.tableFilterMode === "exclude" ? input.tableFilterMode : "all",
|
||||
tablePatterns: normalizeDatabaseBackupTablePatterns(input.tablePatterns),
|
||||
destinationDirectory,
|
||||
frequency,
|
||||
intervalHours: boundedInteger(input.intervalHours, 6, 1, 168),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ pub struct DatabaseExportRequest {
|
|||
pub file_path: String,
|
||||
#[serde(default)]
|
||||
pub selected_tables: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_tables: Vec<String>,
|
||||
pub include_structure: bool,
|
||||
pub include_data: bool,
|
||||
pub include_objects: bool,
|
||||
|
|
@ -891,6 +893,7 @@ async fn list_postgres_export_sequences(
|
|||
pool_key: &str,
|
||||
schema: &str,
|
||||
selected_tables: &[String],
|
||||
excluded_tables: &[String],
|
||||
include_objects: bool,
|
||||
fail_on_error: bool,
|
||||
) -> Result<Vec<PostgresExportSequence>, String> {
|
||||
|
|
@ -932,6 +935,7 @@ async fn list_postgres_export_sequences(
|
|||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect();
|
||||
let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect();
|
||||
let mut sequences = rows
|
||||
.iter()
|
||||
.map(|row| PostgresExportSequence {
|
||||
|
|
@ -948,8 +952,9 @@ async fn list_postgres_export_sequences(
|
|||
owner_column: row.get::<_, Option<String>>(9),
|
||||
})
|
||||
.filter(|sequence| {
|
||||
selected.is_empty()
|
||||
|| sequence.owner_table.as_deref().map(|owner_table| selected.contains(owner_table)).unwrap_or(false)
|
||||
sequence.owner_table.as_deref().is_none_or(|owner_table| !excluded.contains(owner_table))
|
||||
&& (selected.is_empty()
|
||||
|| sequence.owner_table.as_deref().is_some_and(|owner_table| selected.contains(owner_table)))
|
||||
})
|
||||
.filter(|sequence| sequence.owner_table.is_some() || (include_objects && selected.is_empty()))
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -1139,7 +1144,7 @@ pub async fn export_database_sql_core(
|
|||
None,
|
||||
)
|
||||
.await?;
|
||||
let all_tables = filter_selected_table_infos(all_tables, &request.selected_tables);
|
||||
let all_tables = filter_export_table_infos(all_tables, &request.selected_tables, &request.excluded_tables);
|
||||
|
||||
// 4. Create file
|
||||
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?;
|
||||
|
|
@ -1165,6 +1170,7 @@ pub async fn export_database_sql_core(
|
|||
&pool_key,
|
||||
&request.schema,
|
||||
&request.selected_tables,
|
||||
&request.excluded_tables,
|
||||
request.include_objects,
|
||||
request.fail_on_error,
|
||||
)
|
||||
|
|
@ -1783,15 +1789,18 @@ pub async fn export_database_sql_core(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_selected_table_infos(
|
||||
fn filter_export_table_infos(
|
||||
tables: Vec<crate::types::TableInfo>,
|
||||
selected_tables: &[String],
|
||||
excluded_tables: &[String],
|
||||
) -> Vec<crate::types::TableInfo> {
|
||||
if selected_tables.is_empty() {
|
||||
return tables;
|
||||
}
|
||||
let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect();
|
||||
tables.into_iter().filter(|table| selected.contains(table.name.as_str())).collect()
|
||||
let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect();
|
||||
tables
|
||||
.into_iter()
|
||||
.filter(|table| selected.is_empty() || selected.contains(table.name.as_str()))
|
||||
.filter(|table| !excluded.contains(table.name.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String {
|
||||
|
|
@ -1802,11 +1811,11 @@ fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseTy
|
|||
mod tests {
|
||||
use super::concurrent_metadata_prefetch_allowed;
|
||||
use super::{
|
||||
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql,
|
||||
filter_selected_table_infos, format_export_sql_literal, generate_postgres_sequence_create_ddl,
|
||||
generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql, normalize_export_table_ddl,
|
||||
record_export_error, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql,
|
||||
PostgresExportSequence, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
|
||||
build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql, filter_export_table_infos,
|
||||
format_export_sql_literal, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl,
|
||||
generate_postgres_sequence_setval_sql, normalize_export_table_ddl, record_export_error,
|
||||
BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, PostgresExportSequence,
|
||||
DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT,
|
||||
};
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::types::TableInfo;
|
||||
|
|
@ -1848,7 +1857,7 @@ mod tests {
|
|||
fn filters_export_tables_by_selected_names() {
|
||||
let tables = vec![table("users", "TABLE"), table("orders", "TABLE"), table("active_users", "VIEW")];
|
||||
|
||||
let filtered = filter_selected_table_infos(tables, &["active_users".to_string(), "users".to_string()]);
|
||||
let filtered = filter_export_table_infos(tables, &["active_users".to_string(), "users".to_string()], &[]);
|
||||
|
||||
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users", "active_users"]);
|
||||
}
|
||||
|
|
@ -1857,11 +1866,30 @@ mod tests {
|
|||
fn keeps_all_export_tables_when_selection_is_empty() {
|
||||
let tables = vec![table("users", "TABLE"), table("orders", "TABLE")];
|
||||
|
||||
let filtered = filter_selected_table_infos(tables.clone(), &[]);
|
||||
let filtered = filter_export_table_infos(tables.clone(), &[], &[]);
|
||||
|
||||
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users", "orders"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_export_tables_by_name() {
|
||||
let tables = vec![table("users", "TABLE"), table("audit_log", "TABLE"), table("active_users", "VIEW")];
|
||||
|
||||
let filtered = filter_export_table_infos(tables, &[], &["audit_log".to_string(), "active_users".to_string()]);
|
||||
|
||||
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusions_take_precedence_over_selected_tables() {
|
||||
let tables = vec![table("users", "TABLE"), table("orders", "TABLE")];
|
||||
|
||||
let filtered =
|
||||
filter_export_table_infos(tables, &["users".to_string(), "orders".to_string()], &["orders".to_string()]);
|
||||
|
||||
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_drop_table_if_exists_with_qualified_mysql_name() {
|
||||
let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql);
|
||||
|
|
|
|||
|
|
@ -2918,14 +2918,26 @@ fn postgres_transaction_begin_sql(consistent_snapshot: bool) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn mysql_transaction_begin_sql(consistent_snapshot: bool) -> &'static str {
|
||||
fn mysql_transaction_begin_sql_candidates(consistent_snapshot: bool) -> &'static [&'static str] {
|
||||
if consistent_snapshot {
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY"
|
||||
&[
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY",
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT",
|
||||
"START TRANSACTION",
|
||||
]
|
||||
} else {
|
||||
"START TRANSACTION"
|
||||
&["START TRANSACTION"]
|
||||
}
|
||||
}
|
||||
|
||||
fn mysql_transaction_isolation_sql(consistent_snapshot: bool) -> Option<&'static str> {
|
||||
consistent_snapshot.then_some("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
||||
}
|
||||
|
||||
fn mysql_error_is_syntax_error(error: &mysql_async::Error) -> bool {
|
||||
matches!(error, mysql_async::Error::Server(server_error) if server_error.code == 1064)
|
||||
}
|
||||
|
||||
async fn begin_transaction_session(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
|
@ -2968,8 +2980,25 @@ async fn begin_transaction_session(
|
|||
}
|
||||
TxnPoolHandle::Mysql(mysql_pool) => {
|
||||
let mut conn = mysql_pool.get_conn().await.map_err(|e| format!("Failed to get MySQL connection: {e}"))?;
|
||||
let begin_sql = mysql_transaction_begin_sql(consistent_snapshot);
|
||||
conn.query_drop(begin_sql).await.map_err(|e| format!("START TRANSACTION failed: {e}"))?;
|
||||
if let Some(isolation_sql) = mysql_transaction_isolation_sql(consistent_snapshot) {
|
||||
conn.query_drop(isolation_sql).await.map_err(|e| format!("SET TRANSACTION failed: {e}"))?;
|
||||
}
|
||||
let mut syntax_errors = Vec::new();
|
||||
for begin_sql in mysql_transaction_begin_sql_candidates(consistent_snapshot) {
|
||||
match conn.query_drop(*begin_sql).await {
|
||||
Ok(()) => {
|
||||
syntax_errors.clear();
|
||||
break;
|
||||
}
|
||||
Err(error) if mysql_error_is_syntax_error(&error) => {
|
||||
syntax_errors.push(format!("{begin_sql}: {error}"));
|
||||
}
|
||||
Err(error) => return Err(format!("START TRANSACTION failed: {error}")),
|
||||
}
|
||||
}
|
||||
if !syntax_errors.is_empty() {
|
||||
return Err(format!("START TRANSACTION failed for all compatible forms: {}", syntax_errors.join("; ")));
|
||||
}
|
||||
TxnConnection::Mysql(conn)
|
||||
}
|
||||
};
|
||||
|
|
@ -4956,8 +4985,35 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn database_backup_transactions_request_consistent_read_only_snapshots() {
|
||||
fn database_backup_transactions_request_consistent_snapshots() {
|
||||
assert_eq!(postgres_transaction_begin_sql(true), "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");
|
||||
assert_eq!(mysql_transaction_begin_sql(true), "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY");
|
||||
assert_eq!(mysql_transaction_isolation_sql(true), Some("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"));
|
||||
assert_eq!(
|
||||
mysql_transaction_begin_sql_candidates(true),
|
||||
[
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY",
|
||||
"START TRANSACTION WITH CONSISTENT SNAPSHOT",
|
||||
"START TRANSACTION",
|
||||
]
|
||||
);
|
||||
assert_eq!(mysql_transaction_isolation_sql(false), None);
|
||||
assert_eq!(mysql_transaction_begin_sql_candidates(false), ["START TRANSACTION"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_backup_transaction_only_falls_back_for_syntax_errors() {
|
||||
let syntax_error = mysql_async::Error::Server(mysql_async::ServerError {
|
||||
code: 1064,
|
||||
message: "unsupported transaction characteristic".to_string(),
|
||||
state: "42000".to_string(),
|
||||
});
|
||||
let permission_error = mysql_async::Error::Server(mysql_async::ServerError {
|
||||
code: 1044,
|
||||
message: "access denied".to_string(),
|
||||
state: "42000".to_string(),
|
||||
});
|
||||
|
||||
assert!(mysql_error_is_syntax_error(&syntax_error));
|
||||
assert!(!mysql_error_is_syntax_error(&permission_error));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ async fn database_export_writes_structure_and_data_for_all_tables() {
|
|||
schema: "public".to_string(),
|
||||
file_path: file_path.to_string_lossy().to_string(),
|
||||
selected_tables: Vec::new(),
|
||||
excluded_tables: Vec::new(),
|
||||
include_structure: true,
|
||||
include_data: true,
|
||||
include_objects: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, nextDatabaseBackupRunAt, normalizeDatabaseBackupSchedule, resolveScheduledDatabaseBackupTargets, supportsScheduledDatabaseBackup, type DatabaseBackupRun, type DatabaseBackupSchedule } from "../../apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts";
|
||||
import {
|
||||
databaseBackupFilePath,
|
||||
databaseBackupRunsToPrune,
|
||||
databaseBackupScheduleIsDue,
|
||||
databaseBackupTableNamesAreCaseSensitive,
|
||||
nextDatabaseBackupRunAt,
|
||||
normalizeDatabaseBackupSchedule,
|
||||
normalizeDatabaseBackupTablePatterns,
|
||||
resolveScheduledDatabaseBackupTableScope,
|
||||
resolveScheduledDatabaseBackupTargets,
|
||||
supportsScheduledDatabaseBackup,
|
||||
type DatabaseBackupRun,
|
||||
type DatabaseBackupSchedule,
|
||||
} from "../../apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts";
|
||||
|
||||
function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): DatabaseBackupSchedule {
|
||||
return {
|
||||
|
|
@ -9,6 +22,8 @@ function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): DatabaseBack
|
|||
enabled: true,
|
||||
connectionId: "connection-1",
|
||||
databases: [],
|
||||
tableFilterMode: "all",
|
||||
tablePatterns: [],
|
||||
destinationDirectory: "C:\\backups",
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
|
|
@ -78,6 +93,21 @@ test("normalization deduplicates databases and bounds schedule values", () => {
|
|||
assert.equal(normalized.timeOfDay, "02:00");
|
||||
});
|
||||
|
||||
test("normalization migrates old schedules and deduplicates table patterns", () => {
|
||||
const legacy = { ...schedule(), tableFilterMode: undefined, tablePatterns: undefined };
|
||||
const normalizedLegacy = normalizeDatabaseBackupSchedule(legacy);
|
||||
const normalizedFiltered = normalizeDatabaseBackupSchedule({
|
||||
...schedule(),
|
||||
tableFilterMode: "exclude",
|
||||
tablePatterns: [" audit_* ", "audit_*", "public.events"],
|
||||
});
|
||||
|
||||
assert.equal(normalizedLegacy?.tableFilterMode, "all");
|
||||
assert.deepEqual(normalizedLegacy?.tablePatterns, []);
|
||||
assert.equal(normalizedFiltered?.tableFilterMode, "exclude");
|
||||
assert.deepEqual(normalizedFiltered?.tablePatterns, ["audit_*", "public.events"]);
|
||||
});
|
||||
|
||||
test("due check respects enabled state and next run", () => {
|
||||
const now = new Date("2026-07-16T12:00:00.000Z");
|
||||
assert.equal(databaseBackupScheduleIsDue(schedule({ nextRunAt: "2026-07-16T11:59:00.000Z" }), now), true);
|
||||
|
|
@ -103,6 +133,18 @@ test("all-database backups use the complete database list", () => {
|
|||
assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["visible", "hidden"]), ["visible", "hidden"]);
|
||||
});
|
||||
|
||||
test("all-database MySQL backups exclude system databases", () => {
|
||||
assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["information_schema", "app", "mysql", "analytics", "performance_schema", "sys"], "mysql"), ["app", "analytics"]);
|
||||
});
|
||||
|
||||
test("all-database PostgreSQL backups exclude template databases", () => {
|
||||
assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["template0", "postgres", "template1", "app"], "postgres"), ["postgres", "app"]);
|
||||
});
|
||||
|
||||
test("explicit system database targets remain available", () => {
|
||||
assert.deepEqual(resolveScheduledDatabaseBackupTargets(["mysql"], ["mysql", "app"], "mysql"), ["mysql"]);
|
||||
});
|
||||
|
||||
test("explicit backup databases fail when any configured target is missing", () => {
|
||||
assert.throws(() => resolveScheduledDatabaseBackupTargets(["app", "renamed"], ["app"]), /renamed/);
|
||||
});
|
||||
|
|
@ -114,6 +156,44 @@ test("scheduled backups are limited to databases with consistent snapshot suppor
|
|||
assert.equal(supportsScheduledDatabaseBackup("sqlserver"), false);
|
||||
});
|
||||
|
||||
test("table pattern input supports exact, wildcard, and qualified rules", () => {
|
||||
assert.deepEqual(normalizeDatabaseBackupTablePatterns(" orders, audit_*; public.events\norders "), ["orders", "audit_*", "public.events"]);
|
||||
|
||||
const included = resolveScheduledDatabaseBackupTableScope("include", ["orders", "audit_*", "public.events"], ["orders", "audit_log", "events", "users"], "app", "public");
|
||||
assert.deepEqual(included, {
|
||||
includedTables: ["orders", "audit_log", "events"],
|
||||
selectedTables: ["orders", "audit_log", "events"],
|
||||
});
|
||||
});
|
||||
|
||||
test("exclude table rules preserve all non-matching tables", () => {
|
||||
const excluded = resolveScheduledDatabaseBackupTableScope("exclude", ["audit_*", "private.*"], ["users", "audit_log", "sessions"], "app", "public");
|
||||
assert.deepEqual(excluded, {
|
||||
includedTables: ["users", "sessions"],
|
||||
excludedTables: ["audit_log"],
|
||||
});
|
||||
});
|
||||
|
||||
test("MySQL table rules respect lower_case_table_names", () => {
|
||||
assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", 0), true);
|
||||
assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", "1"), false);
|
||||
assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", 2), false);
|
||||
assert.equal(databaseBackupTableNamesAreCaseSensitive("postgres", 1), true);
|
||||
assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", undefined), true);
|
||||
|
||||
const caseSensitive = resolveScheduledDatabaseBackupTableScope("exclude", ["orders"], ["orders", "Orders"], "app", "app", true);
|
||||
assert.deepEqual(caseSensitive, {
|
||||
includedTables: ["Orders"],
|
||||
excludedTables: ["orders"],
|
||||
});
|
||||
|
||||
const caseInsensitive = resolveScheduledDatabaseBackupTableScope("exclude", ["orders"], ["orders", "Orders"], "app", "app", false);
|
||||
assert.deepEqual(caseInsensitive, {
|
||||
includedTables: [],
|
||||
excludedTables: ["orders", "Orders"],
|
||||
});
|
||||
});
|
||||
|
||||
test("retention never selects failed backup runs", () => {
|
||||
const failed = { ...run("failed", "2026-07-16T04:00:00.000Z"), status: "failed" as const };
|
||||
const successful = [run("new", "2026-07-16T03:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];
|
||||
|
|
|
|||
Loading…
Reference in New Issue