mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor(backups): use upsert instead of create/update split
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
|||||||
getBackupSchedule,
|
getBackupSchedule,
|
||||||
updateBackupSchedule,
|
updateBackupSchedule,
|
||||||
getBackupScheduleForVolume,
|
getBackupScheduleForVolume,
|
||||||
|
upsertBackupSchedule,
|
||||||
runBackupNow,
|
runBackupNow,
|
||||||
} from "../sdk.gen";
|
} from "../sdk.gen";
|
||||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||||
@@ -75,6 +76,8 @@ import type {
|
|||||||
UpdateBackupScheduleData,
|
UpdateBackupScheduleData,
|
||||||
UpdateBackupScheduleResponse,
|
UpdateBackupScheduleResponse,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
|
UpsertBackupScheduleData,
|
||||||
|
UpsertBackupScheduleResponse,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponse,
|
RunBackupNowResponse,
|
||||||
} from "../types.gen";
|
} 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);
|
export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import type {
|
|||||||
UpdateBackupScheduleResponses,
|
UpdateBackupScheduleResponses,
|
||||||
GetBackupScheduleForVolumeData,
|
GetBackupScheduleForVolumeData,
|
||||||
GetBackupScheduleForVolumeResponses,
|
GetBackupScheduleForVolumeResponses,
|
||||||
|
UpsertBackupScheduleData,
|
||||||
|
UpsertBackupScheduleResponses,
|
||||||
RunBackupNowData,
|
RunBackupNowData,
|
||||||
RunBackupNowResponses,
|
RunBackupNowResponses,
|
||||||
} from "./types.gen";
|
} 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
|
* Trigger a backup immediately for a schedule
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1063,6 +1063,62 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||||||
export type GetBackupScheduleForVolumeResponse =
|
export type GetBackupScheduleForVolumeResponse =
|
||||||
GetBackupScheduleForVolumeResponses[keyof GetBackupScheduleForVolumeResponses];
|
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 = {
|
export type RunBackupNowData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { OnOff } from "~/components/onoff";
|
|||||||
import type { Volume } from "~/lib/types";
|
import type { Volume } from "~/lib/types";
|
||||||
import {
|
import {
|
||||||
listRepositoriesOptions,
|
listRepositoriesOptions,
|
||||||
createBackupScheduleMutation,
|
upsertBackupScheduleMutation,
|
||||||
updateBackupScheduleMutation,
|
|
||||||
getBackupScheduleForVolumeOptions,
|
getBackupScheduleForVolumeOptions,
|
||||||
} from "~/api-client/@tanstack/react-query.gen";
|
} from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/lib/errors";
|
import { parseError } from "~/lib/errors";
|
||||||
@@ -76,27 +75,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
};
|
};
|
||||||
}, [existingSchedule, selectedRepository, volume.name]);
|
}, [existingSchedule, selectedRepository, volume.name]);
|
||||||
|
|
||||||
const createSchedule = useMutation({
|
const upsertSchedule = useMutation({
|
||||||
...createBackupScheduleMutation(),
|
...upsertBackupScheduleMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Backup schedule created successfully");
|
toast.success("Backup schedule saved successfully");
|
||||||
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
|
queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["getBackupScheduleForVolume", volume.id.toString()] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to create backup schedule", {
|
toast.error("Failed to save 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", {
|
|
||||||
description: parseError(error)?.message,
|
description: parseError(error)?.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -113,27 +100,15 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
|
||||||
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
|
||||||
|
|
||||||
if (existingSchedule) {
|
upsertSchedule.mutate({
|
||||||
updateSchedule.mutate({
|
body: {
|
||||||
path: { scheduleId: existingSchedule.id.toString() },
|
volumeId: volume.id,
|
||||||
body: {
|
repositoryId: formValues.repositoryId,
|
||||||
repositoryId: formValues.repositoryId,
|
enabled: existingSchedule ? isEnabled : true,
|
||||||
enabled: isEnabled,
|
cronExpression,
|
||||||
cronExpression,
|
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingRepositories || loadingSchedules) {
|
if (loadingRepositories || loadingSchedules) {
|
||||||
@@ -180,9 +155,9 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
|
|||||||
if (!existingSchedule) return;
|
if (!existingSchedule) return;
|
||||||
|
|
||||||
setIsEnabled(enabled);
|
setIsEnabled(enabled);
|
||||||
updateSchedule.mutate({
|
upsertSchedule.mutate({
|
||||||
path: { scheduleId: existingSchedule.id.toString() },
|
|
||||||
body: {
|
body: {
|
||||||
|
volumeId: existingSchedule.volumeId,
|
||||||
repositoryId: existingSchedule.repositoryId,
|
repositoryId: existingSchedule.repositoryId,
|
||||||
enabled,
|
enabled,
|
||||||
cronExpression: existingSchedule.cronExpression,
|
cronExpression: existingSchedule.cronExpression,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
runBackupNowDto,
|
runBackupNowDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
|
upsertBackupScheduleBody,
|
||||||
|
upsertBackupScheduleDto,
|
||||||
type CreateBackupScheduleDto,
|
type CreateBackupScheduleDto,
|
||||||
type DeleteBackupScheduleDto,
|
type DeleteBackupScheduleDto,
|
||||||
type GetBackupScheduleDto,
|
type GetBackupScheduleDto,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
type ListBackupSchedulesResponseDto,
|
type ListBackupSchedulesResponseDto,
|
||||||
type RunBackupNowDto,
|
type RunBackupNowDto,
|
||||||
type UpdateBackupScheduleDto,
|
type UpdateBackupScheduleDto,
|
||||||
|
type UpsertBackupScheduleDto,
|
||||||
} from "./backups.dto";
|
} from "./backups.dto";
|
||||||
import { backupsService } from "./backups.service";
|
import { backupsService } from "./backups.service";
|
||||||
|
|
||||||
@@ -54,6 +57,13 @@ export const backupScheduleController = new Hono()
|
|||||||
|
|
||||||
return c.json<UpdateBackupScheduleDto>(schedule, 200);
|
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) => {
|
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
|
||||||
const scheduleId = c.req.param("scheduleId");
|
const scheduleId = c.req.param("scheduleId");
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Delete a backup schedule
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/
|
|||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
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";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
|
||||||
const calculateNextRun = (cronExpression: string): number => {
|
const calculateNextRun = (cronExpression: string): number => {
|
||||||
@@ -255,6 +255,77 @@ const getScheduleForVolume = async (volumeId: number) => {
|
|||||||
return schedule ?? null;
|
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 = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getSchedule,
|
getSchedule,
|
||||||
@@ -264,4 +335,5 @@ export const backupsService = {
|
|||||||
executeBackup,
|
executeBackup,
|
||||||
getSchedulesToExecute,
|
getSchedulesToExecute,
|
||||||
getScheduleForVolume,
|
getScheduleForVolume,
|
||||||
|
upsertSchedule,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user