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,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,