feat(backup): support custom backup file name templates and path preview

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 00:29:15 +08:00
1 parent 38be5ba35c
commit ce3bd1c8ed
31 files changed
+744 -125

No files matched your search

@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupTableMatchesPattern, normalizeDatabaseBackupRun, resolveScheduledDatabaseBackupTableScope, type DatabaseBackupRun } from "../../backup/scheduledDatabaseBackup";
import {
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
sanitizeDatabaseBackupFileSegment,
databaseBackupRunsToPrune,
databaseBackupTableMatchesPattern,
normalizeDatabaseBackupRun,
resolveScheduledDatabaseBackupTableScope,
type DatabaseBackupRun,
} from "../../backup/scheduledDatabaseBackup";
const startedAt = "2026-08-12T00:00:00.000Z";
@@ -23,6 +32,24 @@ describe("database backup run persistence", () => {
expect(databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "gzip")).toMatch(/\.sql\.gz$/);
});
it("uses a custom file-name template without implicit run ID suffixes", () => {
const path = databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "none", "before-migration");
expect(path).toBe("/backups/before-migration__app.sql");
});
it("renders all file-name template variables without duplicating suffixes", () => {
const path = databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "none", "{schedule}__{date}__{database}__{runId}");
expect(path).toBe("/backups/Nightly__20260812__app__run-1234.sql");
});
it("rejects file-name templates that could form a path", () => {
expect(databaseBackupFileNamePatternIsValid("before-migration")).toBe(true);
expect(databaseBackupFileNamePatternIsValid("../before-migration")).toBe(false);
expect(databaseBackupFileNamePatternIsValid("before-migration ")).toBe(false);
});
it("accepts a one-shot run without a schedule id", () => {
const normalized = normalizeDatabaseBackupRun(run());
@@ -36,6 +63,12 @@ describe("database backup run persistence", () => {
);
});
it("keeps the backup destination root in persisted run records", () => {
const normalized = normalizeDatabaseBackupRun(run({ destinationDirectory: "/backups" }));
expect(normalized?.destinationDirectory).toBe("/backups");
});
it("keeps legacy runs compatible and treats them as scheduled history", () => {
const normalized = normalizeDatabaseBackupRun(run({ scheduleId: "schedule-1", source: undefined, scheduleName: "Nightly backup" }));
@@ -70,3 +103,16 @@ describe("database backup table pattern matching", () => {
expect(scope.excludedTables).toEqual(["vector_data"]);
});
});
describe("database backup file name segment sanitization", () => {
it("appends an underscore to Windows reserved device names", () => {
for (const reserved of ["con", "PRN", "Aux", "nul", "com3", "LPT9"]) {
expect(sanitizeDatabaseBackupFileSegment(reserved)).toBe(`${reserved}_`);
}
});
it("keeps ordinary segment names unchanged", () => {
expect(sanitizeDatabaseBackupFileSegment("nightly-full")).toBe("nightly-full");
expect(sanitizeDatabaseBackupFileSegment("con_backup")).toBe("con_backup");
});
});
+1 -1
View File
@@ -765,7 +765,7 @@ export async function revealPathInFileManager(_path: string): Promise<void> {
throw new Error("Reveal in file manager is only available in the desktop app.");
}
export async function deleteDatabaseBackupFiles(_paths: string[]): Promise<number> {
export async function deleteDatabaseBackupFiles(_paths: string[], _allowedRoots: string[] = []): Promise<number> {
throw new Error("Database backup file management is only available in the desktop app.");
}
+3 -2
View File
@@ -2427,8 +2427,8 @@ export async function revealPathInFileManager(path: string): Promise<void> {
return invoke("reveal_path_in_file_manager", { path });
}
export async function deleteDatabaseBackupFiles(paths: string[]): Promise<number> {
return invoke("delete_database_backup_files", { paths });
export async function deleteDatabaseBackupFiles(paths: string[], allowedRoots: string[] = []): Promise<number> {
return invoke("delete_database_backup_files", { paths, allowedRoots });
}
export async function isSqliteDatabaseFile(path: string): Promise<boolean> {
@@ -4862,6 +4862,7 @@ export interface DatabaseExportRequest {
dropTableIfExists?: boolean;
omitAutoIncrement?: boolean;
failOnError?: boolean;
preventOverwrite?: boolean;
outputCompression?: "none" | "gzip";
snapshotSessionId?: string;
batchSize: number;
@@ -7,6 +7,7 @@ export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
export const MAX_DATABASE_BACKUP_HISTORY = 200;
export const DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN = "dbx-backup__{schedule}__{timestamp}__{runId}";
export const DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN = "dbx-backup__{schedule}__{timestamp}__{database}__{runId}";
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
@@ -120,6 +121,8 @@ export interface DatabaseBackupExecutionConfig {
includeObjects: boolean;
dropTableIfExists: boolean;
outputCompression: DatabaseBackupOutputCompression;
/** File-name template for each database target. The final extension is added automatically. */
fileNamePattern?: string;
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
@@ -152,6 +155,7 @@ export function toDatabaseBackupExecutionConfig(schedule: DatabaseBackupSchedule
includeObjects: schedule.includeObjects,
dropTableIfExists: schedule.dropTableIfExists,
outputCompression: schedule.outputCompression,
fileNamePattern: schedule.fileNamePattern,
};
}
@@ -169,6 +173,8 @@ export interface DatabaseBackupRun {
displayName?: string;
connectionId: string;
connectionName: string;
/** Selected root used to constrain cleanup of custom-named backup files. */
destinationDirectory?: string;
trigger: DatabaseBackupRunTrigger;
source: DatabaseBackupRunSource;
status: DatabaseBackupRunStatus;
@@ -251,6 +257,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
includeObjects: booleanValue(input.includeObjects, true),
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
outputCompression: input.outputCompression === "gzip" ? "gzip" : "none",
fileNamePattern: normalizeDatabaseBackupFileNamePattern(input.fileNamePattern),
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
runDirectoryPattern: normalizeDatabaseBackupRunDirectoryPattern(input.runDirectoryPattern),
createdAt: validIsoDate(input.createdAt, nowIso),
@@ -316,6 +323,7 @@ export function normalizeDatabaseBackupRun(value: unknown): DatabaseBackupRun |
displayName: stringValue(input.displayName).trim() || undefined,
connectionId: stringValue(input.connectionId).trim(),
connectionName: stringValue(input.connectionName).trim(),
destinationDirectory: stringValue(input.destinationDirectory).trim() || undefined,
trigger: input.trigger === "scheduled" ? "scheduled" : "manual",
source: input.source === "one-shot" ? "one-shot" : "scheduled",
status,
@@ -388,6 +396,10 @@ export function sanitizeDatabaseBackupFileSegment(value: string): string {
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/[. ]+$/g, "")
.trim();
// Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)不能作为文件名主体,补下划线规避。
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(sanitized)) {
return `${sanitized}_`;
}
return sanitized || "database";
}
@@ -429,6 +441,35 @@ export function databaseBackupRunDirectoryPatternIsValid(pattern: string): boole
}
}
/**
* A file-name template stays within the selected output directory. Empty and
* legacy configurations retain the previous file-name format.
*/
export function normalizeDatabaseBackupFileNamePattern(value: unknown): string {
return stringValue(value).trim() || DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN;
}
export function databaseBackupFileNamePatternIsValid(pattern: string): boolean {
const raw = stringValue(pattern);
if (!raw.trim()) return true;
return raw === raw.trim() && !/[\\/:*?"<>|]/.test(raw) && !/[. ]$/.test(raw);
}
function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string): string {
const normalized = normalizeDatabaseBackupFileNamePattern(pattern);
const timestamp = databaseBackupTimestamp(startedAt);
const variables = {
"{schedule}": sanitizeDatabaseBackupFileSegment(scheduleName),
"{date}": timestamp.slice(0, 8),
"{timestamp}": timestamp,
"{database}": sanitizeDatabaseBackupFileSegment(fileStem),
"{runId}": sanitizeDatabaseBackupFileSegment(runId).slice(0, 8),
};
const rendered = Object.entries(variables).reduce((value, [token, replacement]) => value.replaceAll(token, replacement), normalized);
const requiredSuffixes = [normalized.includes("{database}") ? "" : variables["{database}"]].filter(Boolean);
return [sanitizeDatabaseBackupFileSegment(rendered), ...requiredSuffixes].join("__");
}
export function databaseBackupTimestamp(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
const part = (number: number) => String(number).padStart(2, "0");
@@ -440,8 +481,8 @@ export function joinDatabaseBackupPath(directory: string, fileName: string): str
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none"): string {
export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string): string {
const suffix = outputCompression === "gzip" ? ".sql.gz" : ".sql";
const fileName = `dbx-backup__${sanitizeDatabaseBackupFileSegment(scheduleName)}__${databaseBackupTimestamp(startedAt)}__${sanitizeDatabaseBackupFileSegment(fileStem)}__${sanitizeDatabaseBackupFileSegment(runId).slice(0, 8)}${suffix}`;
const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId)}${suffix}`;
return joinDatabaseBackupPath(directory, fileName);
}