refactor(backups): use upsert instead of create/update split

This commit is contained in:
Nicolas Meienberger
2025-10-29 21:14:41 +01:00
parent e335133237
commit 9628310d53
7 changed files with 236 additions and 43 deletions

View File

@@ -29,6 +29,7 @@ import {
getBackupSchedule,
updateBackupSchedule,
getBackupScheduleForVolume,
upsertBackupSchedule,
runBackupNow,
} from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
@@ -75,6 +76,8 @@ import type {
UpdateBackupScheduleData,
UpdateBackupScheduleResponse,
GetBackupScheduleForVolumeData,
UpsertBackupScheduleData,
UpsertBackupScheduleResponse,
RunBackupNowData,
RunBackupNowResponse,
} from "../types.gen";
@@ -865,6 +868,29 @@ export const getBackupScheduleForVolumeOptions = (options: Options<GetBackupSche
});
};
/**
* Create or update a backup schedule for a volume
*/
export const upsertBackupScheduleMutation = (
options?: Partial<Options<UpsertBackupScheduleData>>,
): UseMutationOptions<UpsertBackupScheduleResponse, DefaultError, Options<UpsertBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<
UpsertBackupScheduleResponse,
DefaultError,
Options<UpsertBackupScheduleData>
> = {
mutationFn: async (localOptions) => {
const { data } = await upsertBackupSchedule({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options);
/**

View File

@@ -62,6 +62,8 @@ import type {
UpdateBackupScheduleResponses,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
UpsertBackupScheduleData,
UpsertBackupScheduleResponses,
RunBackupNowData,
RunBackupNowResponses,
} from "./types.gen";
@@ -426,6 +428,22 @@ export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>
});
};
/**
* Create or update a backup schedule for a volume
*/
export const upsertBackupSchedule = <ThrowOnError extends boolean = false>(
options?: Options<UpsertBackupScheduleData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).put<UpsertBackupScheduleResponses, unknown, ThrowOnError>({
url: "/api/v1/backups/upsert",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
};
/**
* Trigger a backup immediately for a schedule
*/

View File

@@ -1063,6 +1063,62 @@ export type GetBackupScheduleForVolumeResponses = {
export type GetBackupScheduleForVolumeResponse =
GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses];
export type UpsertBackupScheduleData = {
body?: {
cronExpression: string;
enabled: boolean;
repositoryId: string;
volumeId: number;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
};
tags?: Array<string>;
};
path?: never;
query?: never;
url: "/api/v1/backups/upsert";
};
export type UpsertBackupScheduleResponses = {
/**
* Backup schedule upserted successfully
*/
200: {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: "error" | "success" | null;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
keepDaily?: number;
keepHourly?: number;
keepLast?: number;
keepMonthly?: number;
keepWeekly?: number;
keepWithinDuration?: string;
keepYearly?: number;
} | null;
updatedAt: number;
volumeId: number;
};
};
export type UpsertBackupScheduleResponse = UpsertBackupScheduleResponses[keyof UpsertBackupScheduleResponses];
export type RunBackupNowData = {
body?: never;
path: {

View File

@@ -9,8 +9,7 @@ import { OnOff } from "~/components/onoff";
import type { Volume } from "~/lib/types";
import {
listRepositoriesOptions,
createBackupScheduleMutation,
updateBackupScheduleMutation,
upsertBackupScheduleMutation,
getBackupScheduleForVolumeOptions,
} from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
@@ -76,27 +75,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
};
}, [existingSchedule, selectedRepository, volume.name]);
const createSchedule = useMutation({
...createBackupScheduleMutation(),
const upsertSchedule = useMutation({
...upsertBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule created successfully");
toast.success("Backup schedule saved successfully");
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
queryClient.invalidateQueries({ queryKey: ["getBackupScheduleForVolume", volume.id.toString()] });
},
onError: (error) => {
toast.error("Failed to create backup schedule", {
description: parseError(error)?.message,
});
},
});
const updateSchedule = useMutation({
...updateBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule updated successfully");
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
},
onError: (error) => {
toast.error("Failed to update backup schedule", {
toast.error("Failed to save backup schedule", {
description: parseError(error)?.message,
});
},
@@ -113,27 +100,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
if (existingSchedule) {
updateSchedule.mutate({
path: { scheduleId: existingSchedule.id.toString() },
body: {
repositoryId: formValues.repositoryId,
enabled: isEnabled,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
},
});
} else {
createSchedule.mutate({
body: {
volumeId: volume.id,
repositoryId: formValues.repositoryId,
enabled: true,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
},
});
}
upsertSchedule.mutate({
body: {
volumeId: volume.id,
repositoryId: formValues.repositoryId,
enabled: existingSchedule ? isEnabled : true,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
},
});
};
if (loadingRepositories || loadingSchedules) {
@@ -180,9 +155,9 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
if (!existingSchedule) return;
setIsEnabled(enabled);
updateSchedule.mutate({
path: { scheduleId: existingSchedule.id.toString() },
upsertSchedule.mutate({
body: {
volumeId: existingSchedule.volumeId,
repositoryId: existingSchedule.repositoryId,
enabled,
cronExpression: existingSchedule.cronExpression,

View File

@@ -10,6 +10,8 @@ import {
runBackupNowDto,
updateBackupScheduleBody,
updateBackupScheduleDto,
upsertBackupScheduleBody,
upsertBackupScheduleDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
@@ -17,6 +19,7 @@ import {
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type UpdateBackupScheduleDto,
type UpsertBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
@@ -54,6 +57,13 @@ export const backupScheduleController = new Hono()
return c.json<UpdateBackupScheduleDto>(schedule, 200);
})
.put("/upsert", upsertBackupScheduleDto, validator("json", upsertBackupScheduleBody), async (c) => {
const body = c.req.valid("json");
const schedule = await backupsService.upsertSchedule(body);
return c.json<UpsertBackupScheduleDto>(schedule, 200);
})
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId");

View File

@@ -169,6 +169,42 @@ export const updateBackupScheduleDto = describeRoute({
},
});
/**
* Upsert a backup schedule (create or update)
*/
export const upsertBackupScheduleBody = type({
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
export type UpsertBackupScheduleBody = typeof upsertBackupScheduleBody.infer;
export const upsertBackupScheduleResponse = backupScheduleSchema;
export type UpsertBackupScheduleDto = typeof upsertBackupScheduleResponse.infer;
export const upsertBackupScheduleDto = describeRoute({
description: "Create or update a backup schedule for a volume",
operationId: "upsertBackupSchedule",
tags: ["Backups"],
responses: {
200: {
description: "Backup schedule upserted successfully",
content: {
"application/json": {
schema: resolver(upsertBackupScheduleResponse),
},
},
},
},
});
/**
* Delete a backup schedule
*/

View File

@@ -7,7 +7,7 @@ import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpsertBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
const calculateNextRun = (cronExpression: string): number => {
@@ -255,6 +255,77 @@ const getScheduleForVolume = async (volumeId: number) => {
return schedule ?? null;
};
const upsertSchedule = async (data: UpsertBackupScheduleBody) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
if (!volume) {
throw new NotFoundError("Volume not found");
}
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const existingSchedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.volumeId, data.volumeId),
});
const nextBackupAt = calculateNextRun(data.cronExpression);
if (existingSchedule) {
const [updated] = await db
.update(backupSchedulesTable)
.set({
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, existingSchedule.id))
.returning();
if (!updated) {
throw new Error("Failed to update backup schedule");
}
return updated;
}
const [newSchedule] = await db
.insert(backupSchedulesTable)
.values({
volumeId: data.volumeId,
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
})
.returning();
if (!newSchedule) {
throw new Error("Failed to create backup schedule");
}
return newSchedule;
};
export const backupsService = {
listSchedules,
getSchedule,
@@ -264,4 +335,5 @@ export const backupsService = {
executeBackup,
getSchedulesToExecute,
getScheduleForVolume,
upsertSchedule,
};