refactor: implement shape stripping for notification, repository, and volume configurations

This commit is contained in:
Jakub Trávník 2025-12-17 14:51:31 +01:00
parent 1ea5c042f5
commit aa957711e5
15 changed files with 251 additions and 76 deletions

View file

@ -17,7 +17,8 @@ import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
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({
name: "2<=string<=32",
@ -26,6 +27,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn;
export const toNotificationConfig = (values: NotificationFormValues): NotificationConfig =>
stripDiscriminatedUnion(values, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
type Props = {
onSubmit: (values: NotificationFormValues) => void;
mode?: "create" | "update";

View file

@ -9,7 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-notification";
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 = {
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
@ -38,7 +38,7 @@ export default function CreateNotification() {
});
const handleSubmit = (values: NotificationFormValues) => {
createNotification.mutate({ body: { name: values.name, config: values } });
createNotification.mutate({ body: { name: values.name, config: toNotificationConfig(values) } });
};
return (

View file

@ -26,7 +26,7 @@ import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
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 = {
breadcrumb: (match: Route.MetaArgs) => [
@ -109,7 +109,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
path: { id: String(data.id) },
body: {
name: values.name,
config: values,
config: toNotificationConfig(values),
},
});
};
@ -172,7 +172,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDescription>
</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">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
<Save className="h-4 w-4 mr-2" />

View file

@ -20,8 +20,9 @@ import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
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 { stripDiscriminatedUnion } from "~/utils/object";
import {
LocalRepositoryForm,
S3RepositoryForm,
@ -41,6 +42,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
export const toRepositoryConfig = (values: RepositoryFormValues): RepositoryConfig =>
stripDiscriminatedUnion(values, "backend", REPOSITORY_CONFIG_SHAPES) as unknown as RepositoryConfig;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";

View file

@ -7,6 +7,7 @@ import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-qu
import {
CreateRepositoryForm,
type RepositoryFormValues,
toRepositoryConfig,
} from "~/client/modules/repositories/components/create-repository-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
@ -43,7 +44,7 @@ export default function CreateRepository() {
const handleSubmit = (values: RepositoryFormValues) => {
createRepository.mutate({
body: {
config: values,
config: toRepositoryConfig(values),
name: values.name,
compressionMode: values.compressionMode,
},

View file

@ -23,6 +23,8 @@ import { testConnectionMutation } from "../../../api-client/@tanstack/react-quer
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
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({
name: "2<=string<=32",
@ -31,6 +33,9 @@ const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;
export const toBackendConfig = (values: FormValues): BackendConfig =>
stripDiscriminatedUnion(values, "backend", VOLUME_CONFIG_SHAPES) as unknown as BackendConfig;
type Props = {
onSubmit: (values: FormValues) => void;
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") {
testBackendConnection.mutate({
body: { config: formValues },
body: { config: toBackendConfig(formValues) },
});
}
};

View file

@ -4,7 +4,7 @@ import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
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 { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
@ -40,7 +40,7 @@ export default function CreateVolume() {
const handleSubmit = (values: FormValues) => {
createVolume.mutate({
body: {
config: values,
config: toBackendConfig(values),
name: values.name,
},
});

View file

@ -3,7 +3,7 @@ import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
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 {
AlertDialog,
AlertDialogAction,
@ -59,7 +59,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
if (pendingValues) {
updateMutation.mutate({
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)]">
<Card className="p-6">
<CreateVolumeForm
initialValues={{ ...volume, ...volume.config }}
initialValues={{ name: volume.name, ...volume.config }}
onSubmit={handleSubmit}
mode="update"
loading={updateMutation.isPending}

View file

@ -13,7 +13,7 @@ export const NOTIFICATION_TYPES = {
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
export const emailNotificationConfigSchema = type({
export const emailNotificationConfigShape = {
type: "'email'",
smtpHost: "string",
smtpPort: "1 <= number <= 65535",
@ -22,59 +22,75 @@ export const emailNotificationConfigSchema = type({
from: "string",
to: "string[]",
useTLS: "boolean",
});
} as const;
export const slackNotificationConfigSchema = type({
export const emailNotificationConfigSchema = type(emailNotificationConfigShape);
export const slackNotificationConfigShape = {
type: "'slack'",
webhookUrl: "string",
channel: "string?",
username: "string?",
iconEmoji: "string?",
});
} as const;
export const discordNotificationConfigSchema = type({
export const slackNotificationConfigSchema = type(slackNotificationConfigShape);
export const discordNotificationConfigShape = {
type: "'discord'",
webhookUrl: "string",
username: "string?",
avatarUrl: "string?",
threadId: "string?",
});
} as const;
export const gotifyNotificationConfigSchema = type({
export const discordNotificationConfigSchema = type(discordNotificationConfigShape);
export const gotifyNotificationConfigShape = {
type: "'gotify'",
serverUrl: "string",
token: "string",
path: "string?",
priority: "0 <= number <= 10",
});
} as const;
export const ntfyNotificationConfigSchema = type({
export const gotifyNotificationConfigSchema = type(gotifyNotificationConfigShape);
export const ntfyNotificationConfigShape = {
type: "'ntfy'",
serverUrl: "string?",
topic: "string",
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
});
} as const;
export const pushoverNotificationConfigSchema = type({
export const ntfyNotificationConfigSchema = type(ntfyNotificationConfigShape);
export const pushoverNotificationConfigShape = {
type: "'pushover'",
userKey: "string",
apiToken: "string",
devices: "string?",
priority: "-1 | 0 | 1",
});
} as const;
export const telegramNotificationConfigSchema = type({
export const pushoverNotificationConfigSchema = type(pushoverNotificationConfigShape);
export const telegramNotificationConfigShape = {
type: "'telegram'",
botToken: "string",
chatId: "string",
});
} as const;
export const customNotificationConfigSchema = type({
export const telegramNotificationConfigSchema = type(telegramNotificationConfigShape);
export const customNotificationConfigShape = {
type: "'custom'",
shoutrrrUrl: "string",
});
} as const;
export const customNotificationConfigSchema = type(customNotificationConfigShape);
export const notificationConfigSchema = emailNotificationConfigSchema
.or(slackNotificationConfigSchema)
@ -87,6 +103,17 @@ export const notificationConfigSchema = emailNotificationConfigSchema
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 = {
start: "start",
success: "success",

View file

@ -14,70 +14,88 @@ export const REPOSITORY_BACKENDS = {
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
// Common fields for all repository configs
const baseRepositoryConfigSchema = type({
export const baseRepositoryConfigShape = {
isExistingRepository: "boolean?",
customPassword: "string?",
});
} as const;
export const s3RepositoryConfigSchema = type({
const baseRepositoryConfigSchema = type(baseRepositoryConfigShape);
export const s3RepositoryConfigShape = {
backend: "'s3'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
} as const;
export const r2RepositoryConfigSchema = type({
export const s3RepositoryConfigSchema = type(s3RepositoryConfigShape).and(baseRepositoryConfigSchema);
export const r2RepositoryConfigShape = {
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
}).and(baseRepositoryConfigSchema);
} as const;
export const localRepositoryConfigSchema = type({
export const r2RepositoryConfigSchema = type(r2RepositoryConfigShape).and(baseRepositoryConfigSchema);
export const localRepositoryConfigShape = {
backend: "'local'",
name: "string",
path: "string?",
}).and(baseRepositoryConfigSchema);
} as const;
export const gcsRepositoryConfigSchema = type({
export const localRepositoryConfigSchema = type(localRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigShape = {
backend: "'gcs'",
bucket: "string",
projectId: "string",
credentialsJson: "string",
}).and(baseRepositoryConfigSchema);
} as const;
export const azureRepositoryConfigSchema = type({
export const gcsRepositoryConfigSchema = type(gcsRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const azureRepositoryConfigShape = {
backend: "'azure'",
container: "string",
accountName: "string",
accountKey: "string",
endpointSuffix: "string?",
}).and(baseRepositoryConfigSchema);
} as const;
export const rcloneRepositoryConfigSchema = type({
export const azureRepositoryConfigSchema = type(azureRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const rcloneRepositoryConfigShape = {
backend: "'rclone'",
remote: "string",
path: "string",
}).and(baseRepositoryConfigSchema);
} as const;
export const restRepositoryConfigSchema = type({
export const rcloneRepositoryConfigSchema = type(rcloneRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const restRepositoryConfigShape = {
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
}).and(baseRepositoryConfigSchema);
} as const;
export const sftpRepositoryConfigSchema = type({
export const restRepositoryConfigSchema = type(restRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigShape = {
backend: "'sftp'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
user: "string",
path: "string",
privateKey: "string",
}).and(baseRepositoryConfigSchema);
} as const;
export const sftpRepositoryConfigSchema = type(sftpRepositoryConfigShape).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
@ -90,6 +108,17 @@ export const repositoryConfigSchema = s3RepositoryConfigSchema
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 = {
off: "off",
auto: "auto",

View file

@ -10,16 +10,18 @@ export const BACKEND_TYPES = {
export type BackendType = keyof typeof BACKEND_TYPES;
export const nfsConfigSchema = type({
export const nfsConfigShape = {
backend: "'nfs'",
server: "string",
exportPath: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
version: "'3' | '4' | '4.1'",
readOnly: "boolean?",
});
} as const;
export const smbConfigSchema = type({
export const nfsConfigSchema = type(nfsConfigShape);
export const smbConfigShape = {
backend: "'smb'",
server: "string",
share: "string",
@ -29,15 +31,19 @@ export const smbConfigSchema = type({
domain: "string?",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
readOnly: "boolean?",
});
} as const;
export const directoryConfigSchema = type({
export const smbConfigSchema = type(smbConfigShape);
export const directoryConfigShape = {
backend: "'directory'",
path: "string",
readOnly: "false?",
});
} as const;
export const webdavConfigSchema = type({
export const directoryConfigSchema = type(directoryConfigShape);
export const webdavConfigShape = {
backend: "'webdav'",
server: "string",
path: "string",
@ -46,19 +52,31 @@ export const webdavConfigSchema = type({
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
readOnly: "boolean?",
ssl: "boolean?",
});
} as const;
export const rcloneConfigSchema = type({
export const webdavConfigSchema = type(webdavConfigShape);
export const rcloneConfigShape = {
backend: "'rclone'",
remote: "string",
path: "string",
readOnly: "boolean?",
});
} as const;
export const rcloneConfigSchema = type(rcloneConfigShape);
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema);
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 = {
mounted: "mounted",
unmounted: "unmounted",

View file

@ -11,8 +11,12 @@ import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger";
import { sendNotification } from "../../utils/shoutrrr";
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 { stripDiscriminatedUnion } from "~/utils/object";
const stripToNotificationConfig = (config: NotificationConfig): NotificationConfig =>
stripDiscriminatedUnion(config, "type", NOTIFICATION_CONFIG_SHAPES) as unknown as NotificationConfig;
const listDestinations = async () => {
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");
}
const encryptedConfig = await encryptSensitiveFields(config);
const processedConfig = stripToNotificationConfig(config);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
const [created] = await db
.insert(notificationDestinationsTable)
.values({
name: slug,
type: config.type,
type: processedConfig.type,
config: encryptedConfig,
})
.returning();
@ -188,9 +193,10 @@ const updateDestination = async (
}
if (updates.config !== undefined) {
const encryptedConfig = await encryptSensitiveFields(updates.config);
const processedConfig = stripToNotificationConfig(updates.config);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
updateData.config = encryptedConfig;
updateData.type = updates.config.type;
updateData.type = processedConfig.type;
}
const [updated] = await db

View file

@ -9,7 +9,11 @@ import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
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 repositories = await db.query.repositoriesTable.findMany({});
@ -65,9 +69,9 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
const id = crypto.randomUUID();
const shortId = generateShortId();
let processedConfig = config;
if (config.backend === "local") {
processedConfig = { ...config, name: shortId };
let processedConfig = stripToRepositoryConfig(config);
if (processedConfig.backend === "local") {
processedConfig = { ...processedConfig, name: shortId };
}
const encryptedConfig = await encryptConfig(processedConfig);
@ -78,7 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
id,
shortId,
name: slug,
type: config.backend,
type: processedConfig.backend,
config: encryptedConfig,
compressionMode: compressionMode ?? "auto",
status: "unknown",
@ -91,7 +95,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
let error: string | null = null;
if (config.isExistingRepository) {
if (processedConfig.isExistingRepository) {
const result = await restic
.snapshots(encryptedConfig)
.then(() => ({ error: null }))

View file

@ -16,7 +16,11 @@ import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
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> {
switch (config.backend) {
@ -53,7 +57,8 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
}
const shortId = generateShortId();
const encryptedConfig = await encryptSensitiveFields(backendConfig);
const processedConfig = stripToBackendConfig(backendConfig);
const encryptedConfig = await encryptSensitiveFields(processedConfig);
const [created] = await db
.insert(volumesTable)
@ -61,7 +66,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
shortId,
name: slug,
config: encryptedConfig,
type: backendConfig.backend,
type: processedConfig.backend,
})
.returning();
@ -192,14 +197,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
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
.update(volumesTable)
.set({
name: newName,
config: encryptedConfig,
type: volumeData.config?.backend,
type: processedConfig?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: Date.now(),
})
@ -226,17 +232,18 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const testConnection = async (backendConfig: BackendConfig) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-test-"));
const sanitizedConfig = stripToBackendConfig(backendConfig);
const mockVolume = {
id: 0,
shortId: "test",
name: "test-connection",
path: tempDir,
config: backendConfig,
config: sanitizedConfig,
createdAt: Date.now(),
updatedAt: Date.now(),
lastHealthCheck: Date.now(),
type: backendConfig.backend,
type: sanitizedConfig.backend,
status: "unmounted" as const,
lastError: null,
autoRemount: true,

View file

@ -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 {
if (Array.isArray(obj)) {
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;
}
/**
* 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);
}