mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(backup): add permanent retention for scheduled backups
This commit is contained in:
@@ -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">
|
||||
|
||||
+59
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -10941,6 +10941,7 @@ export default withEnglishFallback({
|
||||
nextRun: "次回:{time}",
|
||||
nextRunPreview: "次回実行:{time}",
|
||||
keepRuns: "{count} 回保持",
|
||||
keepForever: "永久保存",
|
||||
everyHours: "{count} 時間ごと",
|
||||
dailyAt: "毎日 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
|
||||
@@ -9126,6 +9126,7 @@ export default withEnglishFallback({
|
||||
nextRun: "다음: {time}",
|
||||
nextRunPreview: "다음 실행: {time}",
|
||||
keepRuns: "{count}개 보관",
|
||||
keepForever: "영구 보관",
|
||||
everyHours: "{count}시간마다",
|
||||
dailyAt: "매일 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -10637,6 +10637,7 @@ export default withEnglishFallback({
|
||||
nextRun: "Далее: {time}",
|
||||
nextRunPreview: "Следующий запуск: {time}",
|
||||
keepRuns: "Хранить {count}",
|
||||
keepForever: "Хранить бессрочно",
|
||||
everyHours: "Каждые {count} ч.",
|
||||
dailyAt: "Ежедневно в {time}",
|
||||
weeklyAt: "{weekday} в {time}",
|
||||
|
||||
@@ -11086,6 +11086,7 @@ export default withEnglishFallback({
|
||||
nextRun: "ถัดไป: {time}",
|
||||
nextRunPreview: "การเรียกใช้ถัดไป: {time}",
|
||||
keepRuns: "เก็บ {count} รายการล่าสุด",
|
||||
keepForever: "เก็บไว้อย่างถาวร",
|
||||
everyHours: "ทุก {count} ชั่วโมง",
|
||||
dailyAt: "ทุกวันเวลา {time}",
|
||||
weeklyAt: "{weekday} เวลา {time}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -10422,6 +10422,7 @@ export default withEnglishFallback({
|
||||
nextRun: "下次:{time}",
|
||||
nextRunPreview: "下次执行:{time}",
|
||||
keepRuns: "保留 {count} 次",
|
||||
keepForever: "永久保存",
|
||||
everyHours: "每 {count} 小时",
|
||||
dailyAt: "每天 {time}",
|
||||
weeklyAt: "{weekday} {time}",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user