refactor: implement shape stripping for notification, repository, and volume configurations
This commit is contained in:
parent
1ea5c042f5
commit
aa957711e5
15 changed files with 251 additions and 76 deletions
|
|
@ -17,7 +17,8 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { SecretInput } from "~/client/components/ui/secret-input";
|
import { SecretInput } from "~/client/components/ui/secret-input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import { notificationConfigSchema } from "~/schemas/notifications";
|
import { NOTIFICATION_CONFIG_SHAPES, notificationConfigSchema, type NotificationConfig } from "~/schemas/notifications";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
|
|
@ -26,6 +27,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
export type NotificationFormValues = typeof formSchema.inferIn;
|
export type NotificationFormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
export const toNotificationConfig = (values: NotificationFormValues): NotificationConfig =>
|
||||||
|
stripDiscriminatedUnion(values, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: NotificationFormValues) => void;
|
onSubmit: (values: NotificationFormValues) => void;
|
||||||
mode?: "create" | "update";
|
mode?: "create" | "update";
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import type { Route } from "./+types/create-notification";
|
import type { Route } from "./+types/create-notification";
|
||||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
import { CreateNotificationForm, type NotificationFormValues, toNotificationConfig } from "../components/create-notification-form";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
|
||||||
|
|
@ -38,7 +38,7 @@ export default function CreateNotification() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: NotificationFormValues) => {
|
const handleSubmit = (values: NotificationFormValues) => {
|
||||||
createNotification.mutate({ body: { name: values.name, config: values } });
|
createNotification.mutate({ body: { name: values.name, config: toNotificationConfig(values) } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { cn } from "~/client/lib/utils";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
|
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
import { CreateNotificationForm, type NotificationFormValues, toNotificationConfig } from "../components/create-notification-form";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: (match: Route.MetaArgs) => [
|
breadcrumb: (match: Route.MetaArgs) => [
|
||||||
|
|
@ -109,7 +109,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
||||||
path: { id: String(data.id) },
|
path: { id: String(data.id) },
|
||||||
body: {
|
body: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
config: values,
|
config: toNotificationConfig(values),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -172,7 +172,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
|
<CreateNotificationForm
|
||||||
|
mode="update"
|
||||||
|
formId={formId}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
initialValues={{ name: data.name, ...data.config }}
|
||||||
|
/>
|
||||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
<Button type="submit" form={formId} loading={updateDestination.isPending}>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ import { SecretInput } from "../../../components/ui/secret-input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
|
import { COMPRESSION_MODES, REPOSITORY_CONFIG_SHAPES, repositoryConfigSchema, type RepositoryConfig } from "~/schemas/restic";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
import {
|
import {
|
||||||
LocalRepositoryForm,
|
LocalRepositoryForm,
|
||||||
S3RepositoryForm,
|
S3RepositoryForm,
|
||||||
|
|
@ -41,6 +42,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
export type RepositoryFormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
export const toRepositoryConfig = (values: RepositoryFormValues): RepositoryConfig =>
|
||||||
|
stripDiscriminatedUnion(values, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: RepositoryFormValues) => void;
|
onSubmit: (values: RepositoryFormValues) => void;
|
||||||
mode?: "create" | "update";
|
mode?: "create" | "update";
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-qu
|
||||||
import {
|
import {
|
||||||
CreateRepositoryForm,
|
CreateRepositoryForm,
|
||||||
type RepositoryFormValues,
|
type RepositoryFormValues,
|
||||||
|
toRepositoryConfig,
|
||||||
} from "~/client/modules/repositories/components/create-repository-form";
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
@ -43,7 +44,7 @@ export default function CreateRepository() {
|
||||||
const handleSubmit = (values: RepositoryFormValues) => {
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
createRepository.mutate({
|
createRepository.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config: toRepositoryConfig(values),
|
||||||
name: values.name,
|
name: values.name,
|
||||||
compressionMode: values.compressionMode,
|
compressionMode: values.compressionMode,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import { testConnectionMutation } from "../../../api-client/@tanstack/react-quer
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
|
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
|
||||||
|
import { VOLUME_CONFIG_SHAPES, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = type({
|
||||||
name: "2<=string<=32",
|
name: "2<=string<=32",
|
||||||
|
|
@ -31,6 +33,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
||||||
|
|
||||||
export type FormValues = typeof formSchema.inferIn;
|
export type FormValues = typeof formSchema.inferIn;
|
||||||
|
|
||||||
|
export const toBackendConfig = (values: FormValues): BackendConfig =>
|
||||||
|
stripDiscriminatedUnion(values, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
mode?: "create" | "update";
|
mode?: "create" | "update";
|
||||||
|
|
@ -98,7 +103,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
|
|
||||||
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
|
||||||
testBackendConnection.mutate({
|
testBackendConnection.mutate({
|
||||||
body: { config: formValues },
|
body: { config: toBackendConfig(formValues) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useId } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, type FormValues, toBackendConfig } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
@ -40,7 +40,7 @@ export default function CreateVolume() {
|
||||||
const handleSubmit = (values: FormValues) => {
|
const handleSubmit = (values: FormValues) => {
|
||||||
createVolume.mutate({
|
createVolume.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config: toBackendConfig(values),
|
||||||
name: values.name,
|
name: values.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, type FormValues, toBackendConfig } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -59,7 +59,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
if (pendingValues) {
|
if (pendingValues) {
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
path: { name: volume.name },
|
path: { name: volume.name },
|
||||||
body: { name: pendingValues.name, config: pendingValues },
|
body: { name: pendingValues.name, config: toBackendConfig(pendingValues) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -69,7 +69,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<CreateVolumeForm
|
<CreateVolumeForm
|
||||||
initialValues={{ ...volume, ...volume.config }}
|
initialValues={{ name: volume.name, ...volume.config }}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
mode="update"
|
mode="update"
|
||||||
loading={updateMutation.isPending}
|
loading={updateMutation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const NOTIFICATION_TYPES = {
|
||||||
|
|
||||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||||
|
|
||||||
export const emailNotificationConfigSchema = type({
|
export const emailNotificationConfigShape = {
|
||||||
type: "'email'",
|
type: "'email'",
|
||||||
smtpHost: "string",
|
smtpHost: "string",
|
||||||
smtpPort: "1 <= number <= 65535",
|
smtpPort: "1 <= number <= 65535",
|
||||||
|
|
@ -22,59 +22,75 @@ export const emailNotificationConfigSchema = type({
|
||||||
from: "string",
|
from: "string",
|
||||||
to: "string[]",
|
to: "string[]",
|
||||||
useTLS: "boolean",
|
useTLS: "boolean",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const slackNotificationConfigSchema = type({
|
export const emailNotificationConfigSchema = type(emailNotificationConfigShape);
|
||||||
|
|
||||||
|
export const slackNotificationConfigShape = {
|
||||||
type: "'slack'",
|
type: "'slack'",
|
||||||
webhookUrl: "string",
|
webhookUrl: "string",
|
||||||
channel: "string?",
|
channel: "string?",
|
||||||
username: "string?",
|
username: "string?",
|
||||||
iconEmoji: "string?",
|
iconEmoji: "string?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const discordNotificationConfigSchema = type({
|
export const slackNotificationConfigSchema = type(slackNotificationConfigShape);
|
||||||
|
|
||||||
|
export const discordNotificationConfigShape = {
|
||||||
type: "'discord'",
|
type: "'discord'",
|
||||||
webhookUrl: "string",
|
webhookUrl: "string",
|
||||||
username: "string?",
|
username: "string?",
|
||||||
avatarUrl: "string?",
|
avatarUrl: "string?",
|
||||||
threadId: "string?",
|
threadId: "string?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const gotifyNotificationConfigSchema = type({
|
export const discordNotificationConfigSchema = type(discordNotificationConfigShape);
|
||||||
|
|
||||||
|
export const gotifyNotificationConfigShape = {
|
||||||
type: "'gotify'",
|
type: "'gotify'",
|
||||||
serverUrl: "string",
|
serverUrl: "string",
|
||||||
token: "string",
|
token: "string",
|
||||||
path: "string?",
|
path: "string?",
|
||||||
priority: "0 <= number <= 10",
|
priority: "0 <= number <= 10",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const ntfyNotificationConfigSchema = type({
|
export const gotifyNotificationConfigSchema = type(gotifyNotificationConfigShape);
|
||||||
|
|
||||||
|
export const ntfyNotificationConfigShape = {
|
||||||
type: "'ntfy'",
|
type: "'ntfy'",
|
||||||
serverUrl: "string?",
|
serverUrl: "string?",
|
||||||
topic: "string",
|
topic: "string",
|
||||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
||||||
username: "string?",
|
username: "string?",
|
||||||
password: "string?",
|
password: "string?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const pushoverNotificationConfigSchema = type({
|
export const ntfyNotificationConfigSchema = type(ntfyNotificationConfigShape);
|
||||||
|
|
||||||
|
export const pushoverNotificationConfigShape = {
|
||||||
type: "'pushover'",
|
type: "'pushover'",
|
||||||
userKey: "string",
|
userKey: "string",
|
||||||
apiToken: "string",
|
apiToken: "string",
|
||||||
devices: "string?",
|
devices: "string?",
|
||||||
priority: "-1 | 0 | 1",
|
priority: "-1 | 0 | 1",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const telegramNotificationConfigSchema = type({
|
export const pushoverNotificationConfigSchema = type(pushoverNotificationConfigShape);
|
||||||
|
|
||||||
|
export const telegramNotificationConfigShape = {
|
||||||
type: "'telegram'",
|
type: "'telegram'",
|
||||||
botToken: "string",
|
botToken: "string",
|
||||||
chatId: "string",
|
chatId: "string",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const customNotificationConfigSchema = type({
|
export const telegramNotificationConfigSchema = type(telegramNotificationConfigShape);
|
||||||
|
|
||||||
|
export const customNotificationConfigShape = {
|
||||||
type: "'custom'",
|
type: "'custom'",
|
||||||
shoutrrrUrl: "string",
|
shoutrrrUrl: "string",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
|
export const customNotificationConfigSchema = type(customNotificationConfigShape);
|
||||||
|
|
||||||
export const notificationConfigSchema = emailNotificationConfigSchema
|
export const notificationConfigSchema = emailNotificationConfigSchema
|
||||||
.or(slackNotificationConfigSchema)
|
.or(slackNotificationConfigSchema)
|
||||||
|
|
@ -87,6 +103,17 @@ export const notificationConfigSchema = emailNotificationConfigSchema
|
||||||
|
|
||||||
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
||||||
|
|
||||||
|
export const NOTIFICATION_CONFIG_SHAPES = {
|
||||||
|
email: emailNotificationConfigShape,
|
||||||
|
slack: slackNotificationConfigShape,
|
||||||
|
discord: discordNotificationConfigShape,
|
||||||
|
gotify: gotifyNotificationConfigShape,
|
||||||
|
ntfy: ntfyNotificationConfigShape,
|
||||||
|
pushover: pushoverNotificationConfigShape,
|
||||||
|
telegram: telegramNotificationConfigShape,
|
||||||
|
custom: customNotificationConfigShape,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const NOTIFICATION_EVENTS = {
|
export const NOTIFICATION_EVENTS = {
|
||||||
start: "start",
|
start: "start",
|
||||||
success: "success",
|
success: "success",
|
||||||
|
|
|
||||||
|
|
@ -14,70 +14,88 @@ export const REPOSITORY_BACKENDS = {
|
||||||
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
|
||||||
|
|
||||||
// Common fields for all repository configs
|
// Common fields for all repository configs
|
||||||
const baseRepositoryConfigSchema = type({
|
export const baseRepositoryConfigShape = {
|
||||||
isExistingRepository: "boolean?",
|
isExistingRepository: "boolean?",
|
||||||
customPassword: "string?",
|
customPassword: "string?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const s3RepositoryConfigSchema = type({
|
const baseRepositoryConfigSchema = type(baseRepositoryConfigShape);
|
||||||
|
|
||||||
|
export const s3RepositoryConfigShape = {
|
||||||
backend: "'s3'",
|
backend: "'s3'",
|
||||||
endpoint: "string",
|
endpoint: "string",
|
||||||
bucket: "string",
|
bucket: "string",
|
||||||
accessKeyId: "string",
|
accessKeyId: "string",
|
||||||
secretAccessKey: "string",
|
secretAccessKey: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const r2RepositoryConfigSchema = type({
|
export const s3RepositoryConfigSchema = type(s3RepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const r2RepositoryConfigShape = {
|
||||||
backend: "'r2'",
|
backend: "'r2'",
|
||||||
endpoint: "string",
|
endpoint: "string",
|
||||||
bucket: "string",
|
bucket: "string",
|
||||||
accessKeyId: "string",
|
accessKeyId: "string",
|
||||||
secretAccessKey: "string",
|
secretAccessKey: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const r2RepositoryConfigSchema = type(r2RepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const localRepositoryConfigShape = {
|
||||||
backend: "'local'",
|
backend: "'local'",
|
||||||
name: "string",
|
name: "string",
|
||||||
path: "string?",
|
path: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const gcsRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = type(localRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const gcsRepositoryConfigShape = {
|
||||||
backend: "'gcs'",
|
backend: "'gcs'",
|
||||||
bucket: "string",
|
bucket: "string",
|
||||||
projectId: "string",
|
projectId: "string",
|
||||||
credentialsJson: "string",
|
credentialsJson: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const azureRepositoryConfigSchema = type({
|
export const gcsRepositoryConfigSchema = type(gcsRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const azureRepositoryConfigShape = {
|
||||||
backend: "'azure'",
|
backend: "'azure'",
|
||||||
container: "string",
|
container: "string",
|
||||||
accountName: "string",
|
accountName: "string",
|
||||||
accountKey: "string",
|
accountKey: "string",
|
||||||
endpointSuffix: "string?",
|
endpointSuffix: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const rcloneRepositoryConfigSchema = type({
|
export const azureRepositoryConfigSchema = type(azureRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const rcloneRepositoryConfigShape = {
|
||||||
backend: "'rclone'",
|
backend: "'rclone'",
|
||||||
remote: "string",
|
remote: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const restRepositoryConfigSchema = type({
|
export const rcloneRepositoryConfigSchema = type(rcloneRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const restRepositoryConfigShape = {
|
||||||
backend: "'rest'",
|
backend: "'rest'",
|
||||||
url: "string",
|
url: "string",
|
||||||
username: "string?",
|
username: "string?",
|
||||||
password: "string?",
|
password: "string?",
|
||||||
path: "string?",
|
path: "string?",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
export const sftpRepositoryConfigSchema = type({
|
export const restRepositoryConfigSchema = type(restRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigShape = {
|
||||||
backend: "'sftp'",
|
backend: "'sftp'",
|
||||||
host: "string",
|
host: "string",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
||||||
user: "string",
|
user: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
privateKey: "string",
|
privateKey: "string",
|
||||||
}).and(baseRepositoryConfigSchema);
|
} as const;
|
||||||
|
|
||||||
|
export const sftpRepositoryConfigSchema = type(sftpRepositoryConfigShape).and(baseRepositoryConfigSchema);
|
||||||
|
|
||||||
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
.or(r2RepositoryConfigSchema)
|
.or(r2RepositoryConfigSchema)
|
||||||
|
|
@ -90,6 +108,17 @@ export const repositoryConfigSchema = s3RepositoryConfigSchema
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
export const REPOSITORY_CONFIG_SHAPES = {
|
||||||
|
local: { ...baseRepositoryConfigShape, ...localRepositoryConfigShape },
|
||||||
|
s3: { ...baseRepositoryConfigShape, ...s3RepositoryConfigShape },
|
||||||
|
r2: { ...baseRepositoryConfigShape, ...r2RepositoryConfigShape },
|
||||||
|
gcs: { ...baseRepositoryConfigShape, ...gcsRepositoryConfigShape },
|
||||||
|
azure: { ...baseRepositoryConfigShape, ...azureRepositoryConfigShape },
|
||||||
|
rclone: { ...baseRepositoryConfigShape, ...rcloneRepositoryConfigShape },
|
||||||
|
rest: { ...baseRepositoryConfigShape, ...restRepositoryConfigShape },
|
||||||
|
sftp: { ...baseRepositoryConfigShape, ...sftpRepositoryConfigShape },
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const COMPRESSION_MODES = {
|
export const COMPRESSION_MODES = {
|
||||||
off: "off",
|
off: "off",
|
||||||
auto: "auto",
|
auto: "auto",
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ export const BACKEND_TYPES = {
|
||||||
|
|
||||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||||
|
|
||||||
export const nfsConfigSchema = type({
|
export const nfsConfigShape = {
|
||||||
backend: "'nfs'",
|
backend: "'nfs'",
|
||||||
server: "string",
|
server: "string",
|
||||||
exportPath: "string",
|
exportPath: "string",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
|
||||||
version: "'3' | '4' | '4.1'",
|
version: "'3' | '4' | '4.1'",
|
||||||
readOnly: "boolean?",
|
readOnly: "boolean?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const smbConfigSchema = type({
|
export const nfsConfigSchema = type(nfsConfigShape);
|
||||||
|
|
||||||
|
export const smbConfigShape = {
|
||||||
backend: "'smb'",
|
backend: "'smb'",
|
||||||
server: "string",
|
server: "string",
|
||||||
share: "string",
|
share: "string",
|
||||||
|
|
@ -29,15 +31,19 @@ export const smbConfigSchema = type({
|
||||||
domain: "string?",
|
domain: "string?",
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
||||||
readOnly: "boolean?",
|
readOnly: "boolean?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const directoryConfigSchema = type({
|
export const smbConfigSchema = type(smbConfigShape);
|
||||||
|
|
||||||
|
export const directoryConfigShape = {
|
||||||
backend: "'directory'",
|
backend: "'directory'",
|
||||||
path: "string",
|
path: "string",
|
||||||
readOnly: "false?",
|
readOnly: "false?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const webdavConfigSchema = type({
|
export const directoryConfigSchema = type(directoryConfigShape);
|
||||||
|
|
||||||
|
export const webdavConfigShape = {
|
||||||
backend: "'webdav'",
|
backend: "'webdav'",
|
||||||
server: "string",
|
server: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
|
|
@ -46,19 +52,31 @@ export const webdavConfigSchema = type({
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
||||||
readOnly: "boolean?",
|
readOnly: "boolean?",
|
||||||
ssl: "boolean?",
|
ssl: "boolean?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
export const rcloneConfigSchema = type({
|
export const webdavConfigSchema = type(webdavConfigShape);
|
||||||
|
|
||||||
|
export const rcloneConfigShape = {
|
||||||
backend: "'rclone'",
|
backend: "'rclone'",
|
||||||
remote: "string",
|
remote: "string",
|
||||||
path: "string",
|
path: "string",
|
||||||
readOnly: "boolean?",
|
readOnly: "boolean?",
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
|
export const rcloneConfigSchema = type(rcloneConfigShape);
|
||||||
|
|
||||||
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
|
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
|
||||||
|
|
||||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = typeof volumeConfigSchema.infer;
|
||||||
|
|
||||||
|
export const VOLUME_CONFIG_SHAPES = {
|
||||||
|
nfs: nfsConfigShape,
|
||||||
|
smb: smbConfigShape,
|
||||||
|
webdav: webdavConfigShape,
|
||||||
|
directory: directoryConfigShape,
|
||||||
|
rclone: rcloneConfigShape,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const BACKEND_STATUS = {
|
export const BACKEND_STATUS = {
|
||||||
mounted: "mounted",
|
mounted: "mounted",
|
||||||
unmounted: "unmounted",
|
unmounted: "unmounted",
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,12 @@ import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { sendNotification } from "../../utils/shoutrrr";
|
import { sendNotification } from "../../utils/shoutrrr";
|
||||||
import { buildShoutrrrUrl } from "./builders";
|
import { buildShoutrrrUrl } from "./builders";
|
||||||
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications";
|
import { NOTIFICATION_CONFIG_SHAPES, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
|
|
||||||
|
const stripToNotificationConfig = (config: NotificationConfig): NotificationConfig =>
|
||||||
|
stripDiscriminatedUnion(config, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
|
||||||
|
|
||||||
const listDestinations = async () => {
|
const listDestinations = async () => {
|
||||||
const destinations = await db.query.notificationDestinationsTable.findMany({
|
const destinations = await db.query.notificationDestinationsTable.findMany({
|
||||||
|
|
@ -138,13 +142,14 @@ const createDestination = async (name: string, config: NotificationConfig) => {
|
||||||
throw new ConflictError("Notification destination with this name already exists");
|
throw new ConflictError("Notification destination with this name already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedConfig = await encryptSensitiveFields(config);
|
const processedConfig = stripToNotificationConfig(config);
|
||||||
|
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(notificationDestinationsTable)
|
.insert(notificationDestinationsTable)
|
||||||
.values({
|
.values({
|
||||||
name: slug,
|
name: slug,
|
||||||
type: config.type,
|
type: processedConfig.type,
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
@ -188,9 +193,10 @@ const updateDestination = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.config !== undefined) {
|
if (updates.config !== undefined) {
|
||||||
const encryptedConfig = await encryptSensitiveFields(updates.config);
|
const processedConfig = stripToNotificationConfig(updates.config);
|
||||||
|
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||||
updateData.config = encryptedConfig;
|
updateData.config = encryptedConfig;
|
||||||
updateData.type = updates.config.type;
|
updateData.type = processedConfig.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import { generateShortId } from "../../utils/id";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { repoMutex } from "../../core/repository-mutex";
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
|
import { REPOSITORY_CONFIG_SHAPES, type CompressionMode, type OverwriteMode, type RepositoryConfig } from "~/schemas/restic";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
|
|
||||||
|
const stripToRepositoryConfig = (config: RepositoryConfig): RepositoryConfig =>
|
||||||
|
stripDiscriminatedUnion(config, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
|
||||||
|
|
||||||
const listRepositories = async () => {
|
const listRepositories = async () => {
|
||||||
const repositories = await db.query.repositoriesTable.findMany({});
|
const repositories = await db.query.repositoriesTable.findMany({});
|
||||||
|
|
@ -65,9 +69,9 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
|
|
||||||
let processedConfig = config;
|
let processedConfig = stripToRepositoryConfig(config);
|
||||||
if (config.backend === "local") {
|
if (processedConfig.backend === "local") {
|
||||||
processedConfig = { ...config, name: shortId };
|
processedConfig = { ...processedConfig, name: shortId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedConfig = await encryptConfig(processedConfig);
|
const encryptedConfig = await encryptConfig(processedConfig);
|
||||||
|
|
@ -78,7 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
||||||
id,
|
id,
|
||||||
shortId,
|
shortId,
|
||||||
name: slug,
|
name: slug,
|
||||||
type: config.backend,
|
type: processedConfig.backend,
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
compressionMode: compressionMode ?? "auto",
|
compressionMode: compressionMode ?? "auto",
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
|
|
@ -91,7 +95,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
||||||
|
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
if (config.isExistingRepository) {
|
if (processedConfig.isExistingRepository) {
|
||||||
const result = await restic
|
const result = await restic
|
||||||
.snapshots(encryptedConfig)
|
.snapshots(encryptedConfig)
|
||||||
.then(() => ({ error: null }))
|
.then(() => ({ error: null }))
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ import type { UpdateVolumeBody } from "./volume.dto";
|
||||||
import { getVolumePath } from "./helpers";
|
import { getVolumePath } from "./helpers";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import type { BackendConfig } from "~/schemas/volumes";
|
import { VOLUME_CONFIG_SHAPES, type BackendConfig } from "~/schemas/volumes";
|
||||||
|
import { stripDiscriminatedUnion } from "~/utils/object";
|
||||||
|
|
||||||
|
const stripToBackendConfig = (config: BackendConfig): BackendConfig =>
|
||||||
|
stripDiscriminatedUnion(config, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
|
||||||
|
|
||||||
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
|
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
|
|
@ -53,7 +57,8 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
const encryptedConfig = await encryptSensitiveFields(backendConfig);
|
const processedConfig = stripToBackendConfig(backendConfig);
|
||||||
|
const encryptedConfig = await encryptSensitiveFields(processedConfig);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(volumesTable)
|
.insert(volumesTable)
|
||||||
|
|
@ -61,7 +66,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
||||||
shortId,
|
shortId,
|
||||||
name: slug,
|
name: slug,
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
type: backendConfig.backend,
|
type: processedConfig.backend,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -192,14 +197,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
||||||
await backend.unmount();
|
await backend.unmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined;
|
const processedConfig = volumeData.config ? stripToBackendConfig(volumeData.config) : undefined;
|
||||||
|
const encryptedConfig = processedConfig ? await encryptSensitiveFields(processedConfig) : undefined;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({
|
.set({
|
||||||
name: newName,
|
name: newName,
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
type: volumeData.config?.backend,
|
type: processedConfig?.backend,
|
||||||
autoRemount: volumeData.autoRemount,
|
autoRemount: volumeData.autoRemount,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
|
|
@ -226,17 +232,18 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
|
||||||
|
|
||||||
const testConnection = async (backendConfig: BackendConfig) => {
|
const testConnection = async (backendConfig: BackendConfig) => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
|
||||||
|
const sanitizedConfig = stripToBackendConfig(backendConfig);
|
||||||
|
|
||||||
const mockVolume = {
|
const mockVolume = {
|
||||||
id: 0,
|
id: 0,
|
||||||
shortId: "test",
|
shortId: "test",
|
||||||
name: "test-connection",
|
name: "test-connection",
|
||||||
path: tempDir,
|
path: tempDir,
|
||||||
config: backendConfig,
|
config: sanitizedConfig,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
lastHealthCheck: Date.now(),
|
lastHealthCheck: Date.now(),
|
||||||
type: backendConfig.backend,
|
type: sanitizedConfig.backend,
|
||||||
status: "unmounted" as const,
|
status: "unmounted" as const,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
autoRemount: true,
|
autoRemount: true,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Deeply removes empty-ish values from an object/array tree.
|
||||||
|
*
|
||||||
|
* - Arrays: maps + filters out `undefined`, `null`, and empty strings.
|
||||||
|
* - Objects: recursively cleans values and drops keys whose cleaned value is `undefined` or "".
|
||||||
|
*
|
||||||
|
* Note: this function is intended for building payloads; it does not preserve object prototypes.
|
||||||
|
*/
|
||||||
export function deepClean<T>(obj: T): T {
|
export function deepClean<T>(obj: T): T {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T;
|
return obj.map(deepClean).filter((v) => v !== undefined && v !== null && v !== "") as T;
|
||||||
|
|
@ -12,3 +20,60 @@ export function deepClean<T>(obj: T): T {
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "shape" describes the allowed top-level keys for an object.
|
||||||
|
*
|
||||||
|
* The values are irrelevant; only the keys matter.
|
||||||
|
*/
|
||||||
|
type Shape = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips an object to only the keys present in the provided shape.
|
||||||
|
*
|
||||||
|
* - This is a shallow operation (top-level keys only).
|
||||||
|
* - Missing keys become `undefined` in the returned object.
|
||||||
|
*
|
||||||
|
* This is used to avoid persisting polluted objects (e.g. form state) into DB `config` JSON blobs.
|
||||||
|
*/
|
||||||
|
export function stripToShape<T extends Record<string, unknown>, S extends Shape>(obj: T, shape: S): {
|
||||||
|
[K in keyof S]: K extends keyof T ? T[K] : undefined;
|
||||||
|
} {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(shape)) {
|
||||||
|
result[key] = (obj as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return result as {
|
||||||
|
[K in keyof S]: K extends keyof T ? T[K] : undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips a discriminated-union object to only the keys allowed for its variant.
|
||||||
|
*
|
||||||
|
* Example: given `discriminantKey = "backend"` and a map of backend -> shape,
|
||||||
|
* this returns `stripToShape(obj, shapes[obj.backend])`.
|
||||||
|
*
|
||||||
|
* Fallback behavior:
|
||||||
|
* - If the discriminant value is not a string, or there is no matching shape,
|
||||||
|
* the original object is returned unchanged.
|
||||||
|
*/
|
||||||
|
export function stripDiscriminatedUnion<
|
||||||
|
T extends Record<string, unknown>,
|
||||||
|
DiscriminantKey extends keyof T,
|
||||||
|
Shapes extends Record<string, Shape>,
|
||||||
|
>(
|
||||||
|
obj: T,
|
||||||
|
discriminantKey: DiscriminantKey,
|
||||||
|
shapes: Shapes,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const discriminant = obj[discriminantKey];
|
||||||
|
if (typeof discriminant !== "string") {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
const shape = shapes[discriminant];
|
||||||
|
if (!shape) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
return stripToShape(obj, shape);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue