From 668bf6f02ce838dff6964b02978344aafe1ef23f Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:57:01 +0800 Subject: [PATCH] feat(backup): add permanent retention for scheduled backups --- .../ScheduledDatabaseBackupSettings.vue | 29 ++++++++- .../ScheduledDatabaseBackupSettings.spec.ts | 59 +++++++++++++++++++ apps/desktop/src/i18n/locales/az.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/id.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/ru.ts | 1 + apps/desktop/src/i18n/locales/th.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/backup/scheduledDatabaseBackup.ts | 6 +- .../dbx-core/src/scheduled_backup/engine.rs | 18 +++--- .../dbx-core/src/scheduled_backup/models.rs | 2 +- crates/dbx-core/src/scheduled_backup/tests.rs | 7 +++ .../app-tests/scheduledDatabaseBackup.test.ts | 5 +- 20 files changed, 123 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index 9e581128d7..e27b67bf0f 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -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(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(newBackupConfig()); const activeDraft = computed(() => (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) { {{ databaseScopeLabel(schedule) }} {{ tableScopeLabel(schedule) }} {{ t("databaseBackup.nextRun", { time: formatDate(schedule.nextRunAt) }) }} - {{ t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }} + {{ schedule.retentionCount === 0 ? t("databaseBackup.keepForever") : t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}
@@ -1096,8 +1112,15 @@ async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
- - +
+ + +
+ +
diff --git a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts index 2d59540aaa..9495e9ff48 100644 --- a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts +++ b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts @@ -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("[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("[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, + }), + ); + }); }); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 3d66245f2a..22e7da5f7e 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 011b77ee3b..b66cc69a91 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 83effdbf5a..b096ce59af 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index cc0679bdf7..4ba25b16b9 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 21ee15b3fb..b2cd8933e8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index d842279d07..2f30cf2121 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -10941,6 +10941,7 @@ export default withEnglishFallback({ nextRun: "次回:{time}", nextRunPreview: "次回実行:{time}", keepRuns: "{count} 回保持", + keepForever: "永久保存", everyHours: "{count} 時間ごと", dailyAt: "毎日 {time}", weeklyAt: "{weekday} {time}", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 59e242a695..a7811e97bc 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -9126,6 +9126,7 @@ export default withEnglishFallback({ nextRun: "다음: {time}", nextRunPreview: "다음 실행: {time}", keepRuns: "{count}개 보관", + keepForever: "영구 보관", everyHours: "{count}시간마다", dailyAt: "매일 {time}", weeklyAt: "{weekday} {time}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 2a8e2091bd..5ad6774d7c 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 7c1c4a35e4..1ccb386377 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -10637,6 +10637,7 @@ export default withEnglishFallback({ nextRun: "Далее: {time}", nextRunPreview: "Следующий запуск: {time}", keepRuns: "Хранить {count}", + keepForever: "Хранить бессрочно", everyHours: "Каждые {count} ч.", dailyAt: "Ежедневно в {time}", weeklyAt: "{weekday} в {time}", diff --git a/apps/desktop/src/i18n/locales/th.ts b/apps/desktop/src/i18n/locales/th.ts index 10873f74d0..56ca825411 100644 --- a/apps/desktop/src/i18n/locales/th.ts +++ b/apps/desktop/src/i18n/locales/th.ts @@ -11086,6 +11086,7 @@ export default withEnglishFallback({ nextRun: "ถัดไป: {time}", nextRunPreview: "การเรียกใช้ถัดไป: {time}", keepRuns: "เก็บ {count} รายการล่าสุด", + keepForever: "เก็บไว้อย่างถาวร", everyHours: "ทุก {count} ชั่วโมง", dailyAt: "ทุกวันเวลา {time}", weeklyAt: "{weekday} เวลา {time}", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 9b677d132e..e22b92452a 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a36bc7f88a..0cd33cea1f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -10422,6 +10422,7 @@ export default withEnglishFallback({ nextRun: "下次:{time}", nextRunPreview: "下次执行:{time}", keepRuns: "保留 {count} 次", + keepForever: "永久保存", everyHours: "每 {count} 小时", dailyAt: "每天 {time}", weeklyAt: "{weekday} {time}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 57355cbf31..2d0b29bfb3 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -10913,6 +10913,7 @@ export default withEnglishFallback({ nextRun: "下次:{time}", nextRunPreview: "下次執行:{time}", keepRuns: "保留 {count} 次", + keepForever: "永久保存", everyHours: "每 {count} 小時", dailyAt: "每天 {time}", weeklyAt: "{weekday} {time}", diff --git a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts index 9905143395..44b8308583 100644 --- a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts +++ b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts @@ -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 { diff --git a/crates/dbx-core/src/scheduled_backup/engine.rs b/crates/dbx-core/src/scheduled_backup/engine.rs index ef908546be..b2d685b2f8 100644 --- a/crates/dbx-core/src/scheduled_backup/engine.rs +++ b/crates/dbx-core/src/scheduled_backup/engine.rs @@ -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(()) diff --git a/crates/dbx-core/src/scheduled_backup/models.rs b/crates/dbx-core/src/scheduled_backup/models.rs index bd0ed7ea9a..9028059e02 100644 --- a/crates/dbx-core/src/scheduled_backup/models.rs +++ b/crates/dbx-core/src/scheduled_backup/models.rs @@ -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()); } diff --git a/crates/dbx-core/src/scheduled_backup/tests.rs b/crates/dbx-core/src/scheduled_backup/tests.rs index d0ae0da62b..6674f2872b 100644 --- a/crates/dbx-core/src/scheduled_backup/tests.rs +++ b/crates/dbx-core/src/scheduled_backup/tests.rs @@ -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] diff --git a/packages/app-tests/scheduledDatabaseBackup.test.ts b/packages/app-tests/scheduledDatabaseBackup.test.ts index 785788f8a8..7850b916df 100644 --- a/packages/app-tests/scheduledDatabaseBackup.test.ts +++ b/packages/app-tests/scheduledDatabaseBackup.test.ts @@ -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", () => {