feat(backup): add permanent retention for scheduled backups

This commit is contained in:
lxk955
2026-10-16 09:57:01 +08:00
committed by GitHub
parent 166a0ba226
commit 668bf6f02c
20 changed files with 123 additions and 16 deletions
@@ -26,6 +26,7 @@ import {
databaseBackupRunDirectory,
databaseBackupRunDirectoryPatternIsValid,
nextDatabaseBackupRunAt,
normalizeDatabaseBackupRetention,
normalizeDatabaseBackupTablePatterns,
supportsScheduledDatabaseBackup,
type DatabaseBackupExecutionConfig,
@@ -243,6 +244,18 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
}
const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
const previousPositiveRetention = ref(10);
function togglePermanentRetention(forever: boolean) {
if (forever) {
if (draft.value.retentionCount > 0) {
previousPositiveRetention.value = draft.value.retentionCount;
}
draft.value.retentionCount = 0;
} else {
draft.value.retentionCount = previousPositiveRetention.value > 0 ? previousPositiveRetention.value : 10;
}
}
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"));
@@ -461,6 +474,7 @@ async function openCreateSchedule() {
editingScheduleId.value = "";
const nextDraft = newScheduleDraft();
draft.value = nextDraft;
previousPositiveRetention.value = nextDraft.retentionCount > 0 ? nextDraft.retentionCount : 10;
resetDatabaseScope(nextDraft);
scheduleDialogOpen.value = true;
}
@@ -470,6 +484,7 @@ async function openEditSchedule(schedule: DatabaseBackupSchedule) {
oneShotDialogOpen.value = false;
editingScheduleId.value = schedule.id;
draft.value = { ...schedule, databases: [...schedule.databases], tablePatterns: [...schedule.tablePatterns] };
previousPositiveRetention.value = schedule.retentionCount > 0 ? schedule.retentionCount : 10;
allDatabases.value = schedule.databases.length === 0;
selectedDatabases.value = [...schedule.databases];
tablePatternsInput.value = schedule.tablePatterns.join(", ");
@@ -561,6 +576,7 @@ async function submitSchedule() {
if (desktop) setLastBackupDirectory(draft.value.destinationDirectory);
await saveSchedule({
...draft.value,
retentionCount: normalizeDatabaseBackupRetention(draft.value.retentionCount),
databases: allDatabases.value ? [] : [...selectedDatabases.value],
...backupTablePayload(draft.value),
});
@@ -853,7 +869,7 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<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>
<span>{{ schedule.retentionCount === 0 ? t("databaseBackup.keepForever") : t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}</span>
</div>
</div>
<div class="flex items-center justify-end gap-1">
@@ -1096,8 +1112,15 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</Select>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.retention") }}</Label>
<Input v-model.number="draft.retentionCount" type="number" min="1" max="100" />
<div class="flex items-center justify-between">
<Label>{{ t("databaseBackup.retention") }}</Label>
<label class="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input data-backup-retention-forever type="checkbox" class="h-3.5 w-3.5 rounded border-border accent-primary" :checked="draft.retentionCount === 0" @change="togglePermanentRetention(($event.target as HTMLInputElement).checked)" />
{{ t("databaseBackup.keepForever") }}
</label>
</div>
<Input v-if="draft.retentionCount !== 0" v-model.number="draft.retentionCount" type="number" min="1" max="1000" />
<Input v-else disabled :model-value="t('databaseBackup.keepForever')" class="cursor-not-allowed bg-muted text-muted-foreground" />
</div>
</div>
<div class="space-y-2">
@@ -1151,4 +1151,63 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
await flush();
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("supports setting permanent retention for scheduled backup", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
addScheduleButton().click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
const checkbox = currentDialog().querySelector<HTMLInputElement>("[data-backup-retention-forever]");
if (!checkbox) throw new Error("Permanent retention checkbox not found");
expect(checkbox.checked).toBe(false);
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change"));
await flush();
expect(currentDialog().textContent).toContain(String(i18n.global.t("databaseBackup.keepForever")));
saveScheduleButton().click();
await flush();
expect(mocks.saveSchedule).toHaveBeenCalledWith(
expect.objectContaining({
retentionCount: 0,
}),
);
});
it("displays keep forever in schedule list for schedules with retentionCount 0 and preserves or updates it on edit", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.schedules.push(schedule({ id: "schedule-perm", retentionCount: 0 }));
await mountSettings();
expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.keepForever")));
buttonWithTitle(String(i18n.global.t("databaseBackup.edit"))).click();
await flush();
const checkbox = currentDialog().querySelector<HTMLInputElement>("[data-backup-retention-forever]");
if (!checkbox) throw new Error("Permanent retention checkbox not found");
expect(checkbox.checked).toBe(true);
checkbox.checked = false;
checkbox.dispatchEvent(new Event("change"));
await flush();
saveScheduleButton().click();
await flush();
expect(mocks.saveSchedule).toHaveBeenCalledWith(
expect.objectContaining({
retentionCount: 10,
}),
);
});
});
+1
View File
@@ -8961,6 +8961,7 @@ export default withEnglishFallback({
nextRun: "Növbəti: {time}",
nextRunPreview: "Növbəti icra: {time}",
keepRuns: "{count} icra saxla",
keepForever: "Həmişə saxla",
everyHours: "Hər {count} saatdan bir",
dailyAt: "Hər gün saat {time}",
weeklyAt: "{weekday}, saat {time}",
+1
View File
@@ -10444,6 +10444,7 @@ export default {
nextRun: "Next: {time}",
nextRunPreview: "Next run: {time}",
keepRuns: "Keep {count}",
keepForever: "Keep forever",
everyHours: "Every {count} hours",
dailyAt: "Daily at {time}",
weeklyAt: "{weekday} at {time}",
+1
View File
@@ -11341,6 +11341,7 @@ export default withEnglishFallback({
nextRun: "Próxima: {time}",
nextRunPreview: "Próxima ejecución: {time}",
keepRuns: "Conservar {count} ejecuciones",
keepForever: "Conservar para siempre",
everyHours: "Cada {count} horas",
dailyAt: "Diariamente a las {time}",
weeklyAt: "{weekday} a las {time}",
+1
View File
@@ -9796,6 +9796,7 @@ export default withEnglishFallback({
nextRun: "Berikutnya: {time}",
nextRunPreview: "Proses berikutnya: {time}",
keepRuns: "Simpan {count}",
keepForever: "Simpan selamanya",
everyHours: "Setiap {count} jam",
dailyAt: "Harian pada {time}",
weeklyAt: "{weekday} pada {time}",
+1
View File
@@ -10903,6 +10903,7 @@ export default withEnglishFallback({
nextRun: "Prossima: {time}",
nextRunPreview: "Prossima esecuzione: {time}",
keepRuns: "Conserva {count} esecuzioni",
keepForever: "Conserva per sempre",
everyHours: "Ogni {count} ore",
dailyAt: "Ogni giorno alle {time}",
weeklyAt: "{weekday} alle {time}",
+1
View File
@@ -10941,6 +10941,7 @@ export default withEnglishFallback({
nextRun: "次回:{time}",
nextRunPreview: "次回実行:{time}",
keepRuns: "{count} 回保持",
keepForever: "永久保存",
everyHours: "{count} 時間ごと",
dailyAt: "毎日 {time}",
weeklyAt: "{weekday} {time}",
+1
View File
@@ -9126,6 +9126,7 @@ export default withEnglishFallback({
nextRun: "다음: {time}",
nextRunPreview: "다음 실행: {time}",
keepRuns: "{count}개 보관",
keepForever: "영구 보관",
everyHours: "{count}시간마다",
dailyAt: "매일 {time}",
weeklyAt: "{weekday} {time}",
+1
View File
@@ -10900,6 +10900,7 @@ export default withEnglishFallback({
nextRun: "Próximo: {time}",
nextRunPreview: "Próxima execução: {time}",
keepRuns: "Manter {count} execuções",
keepForever: "Manter para sempre",
everyHours: "A cada {count} horas",
dailyAt: "Diariamente às {time}",
weeklyAt: "{weekday} às {time}",
+1
View File
@@ -10637,6 +10637,7 @@ export default withEnglishFallback({
nextRun: "Далее: {time}",
nextRunPreview: "Следующий запуск: {time}",
keepRuns: "Хранить {count}",
keepForever: "Хранить бессрочно",
everyHours: "Каждые {count} ч.",
dailyAt: "Ежедневно в {time}",
weeklyAt: "{weekday} в {time}",
+1
View File
@@ -11086,6 +11086,7 @@ export default withEnglishFallback({
nextRun: "ถัดไป: {time}",
nextRunPreview: "การเรียกใช้ถัดไป: {time}",
keepRuns: "เก็บ {count} รายการล่าสุด",
keepForever: "เก็บไว้อย่างถาวร",
everyHours: "ทุก {count} ชั่วโมง",
dailyAt: "ทุกวันเวลา {time}",
weeklyAt: "{weekday} เวลา {time}",
+1
View File
@@ -8846,6 +8846,7 @@ export default withEnglishFallback({
nextRun: "Sonraki: {time}",
nextRunPreview: "Sonraki çalıştırma: {time}",
keepRuns: "{count} tanesini sakla",
keepForever: "Süresiz sakla",
everyHours: "Her {count} saatte bir",
dailyAt: "Her gün {time}",
weeklyAt: "{weekday} günü {time}",
+1
View File
@@ -10422,6 +10422,7 @@ export default withEnglishFallback({
nextRun: "下次:{time}",
nextRunPreview: "下次执行:{time}",
keepRuns: "保留 {count} 次",
keepForever: "永久保存",
everyHours: "每 {count} 小时",
dailyAt: "每天 {time}",
weeklyAt: "{weekday} {time}",
+1
View File
@@ -10913,6 +10913,7 @@ export default withEnglishFallback({
nextRun: "下次:{time}",
nextRunPreview: "下次執行:{time}",
keepRuns: "保留 {count} 次",
keepForever: "永久保存",
everyHours: "每 {count} 小時",
dailyAt: "每天 {time}",
weeklyAt: "{weekday} {time}",
@@ -269,7 +269,7 @@ export function normalizeDatabaseBackupTime(value: unknown): string {
}
export function normalizeDatabaseBackupRetention(value: unknown): number {
return boundedInteger(value, 10, 1, 100);
return boundedInteger(value, 10, 0, 1000);
}
export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()): DatabaseBackupSchedule | null {
@@ -436,10 +436,12 @@ export function writeDatabaseBackupRuns(runs: readonly DatabaseBackupRun[]) {
}
export function databaseBackupRunsToPrune(runs: readonly DatabaseBackupRun[], scheduleId: string, retentionCount: number): DatabaseBackupRun[] {
const normalizedRetention = normalizeDatabaseBackupRetention(retentionCount);
if (normalizedRetention === 0) return [];
return runs
.filter((run) => run.scheduleId === scheduleId && run.status === "success")
.sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))
.slice(normalizeDatabaseBackupRetention(retentionCount));
.slice(normalizedRetention);
}
export function sanitizeDatabaseBackupFileSegment(value: string): string {
+10 -8
View File
@@ -113,14 +113,16 @@ impl BackupService {
if success {
let snapshot = self.store.snapshot().await?;
if let Some(schedule) = snapshot.schedules.iter().find(|s| Some(&s.id) == fallback.schedule_id.as_ref()) {
let ids = snapshot
.runs
.iter()
.filter(|r| r.schedule_id.as_ref() == Some(&schedule.id) && r.status == "success")
.skip(schedule.retention_count)
.map(|r| r.id.clone())
.collect();
self.delete_runs(ids).await?;
if schedule.retention_count > 0 {
let ids = snapshot
.runs
.iter()
.filter(|r| r.schedule_id.as_ref() == Some(&schedule.id) && r.status == "success")
.skip(schedule.retention_count)
.map(|r| r.id.clone())
.collect();
self.delete_runs(ids).await?;
}
}
}
Ok(())
@@ -195,7 +195,7 @@ impl BackupSchedule {
if !matches!(self.frequency.as_str(), "hourly" | "daily" | "weekly")
|| !(1..=168).contains(&self.interval_hours)
|| self.weekday > 6
|| !(1..=100).contains(&self.retention_count)
|| !(0..=1000).contains(&self.retention_count)
{
return Err("Invalid backup schedule".into());
}
@@ -54,6 +54,13 @@ fn invalid_schedule_and_path_templates_are_rejected() {
assert!(super::models::matches_pattern("app.public.user?", "users", "app", "public", true));
assert!(!super::models::matches_pattern("PUBLIC.*", "users", "app", "public", true));
assert!(super::models::matches_pattern("PUBLIC.*", "users", "app", "public", false));
schedule.time_zone = "America/New_York".into();
schedule.retention_count = 0;
assert!(schedule.validate().is_ok());
schedule.retention_count = 1000;
assert!(schedule.validate().is_ok());
schedule.retention_count = 1001;
assert!(schedule.validate().is_err());
}
#[test]
+3 -2
View File
@@ -96,14 +96,14 @@ test("normalization deduplicates databases and bounds schedule values", () => {
...schedule(),
databases: ["app", "app", " analytics "],
intervalHours: 999,
retentionCount: 0,
retentionCount: -1,
timeOfDay: "invalid",
});
assert.ok(normalized);
assert.deepEqual(normalized.databases, ["app", "analytics"]);
assert.equal(normalized.intervalHours, 168);
assert.equal(normalized.retentionCount, 1);
assert.equal(normalized.retentionCount, 0);
assert.equal(normalized.timeOfDay, "02:00");
});
@@ -157,6 +157,7 @@ test("retention pruning keeps the newest successful runs", () => {
databaseBackupRunsToPrune(runs, "schedule-1", 2).map((item) => item.id),
["old"],
);
assert.deepEqual(databaseBackupRunsToPrune(runs, "schedule-1", 0), []);
});
test("backup history normalization preserves an independent display name and progress", () => {