feat(backup): support custom backup file name templates and path preview
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user