From a039bb478e968dc84c557835bbe8fca8f46b6fe0 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:27:54 +0100 Subject: [PATCH] fix: separate raw include paths and patterns (#683) Separate include patters and included path cleanly to avoid path with special characters to be expanded. Closes https://github.com/nicotsx/zerobyte/discussions/680 ## Summary by CodeRabbit * **New Features** * Added ability to select specific directories and paths for inclusion in backup schedules, separate from pattern-based rules. * **Bug Fixes & Improvements** * Automatically migrates existing backup configurations to work with the new path selection system. * Enhanced backup restoration to properly handle both selected paths and pattern-based inclusions. * **Chores** * Updated database schema to support path selections in backup schedules. --- app/client/api-client/types.gen.ts | 19 +- .../components/backup-progress-card.tsx | 23 +- .../components/create-schedule-form/index.tsx | 16 +- .../create-schedule-form/paths-section.tsx | 2 +- .../create-schedule-form/summary-section.tsx | 12 +- .../components/create-schedule-form/types.ts | 7 +- .../components/create-schedule-form/utils.ts | 9 +- .../modules/backups/routes/backup-details.tsx | 2 + .../modules/backups/routes/create-backup.tsx | 1 + .../migration.sql | 1 + .../snapshot.json | 2281 +++++++++++++++++ app/schemas/events-dto.ts | 12 +- app/server/db/schema.ts | 1 + .../__tests__/backups.patterns.test.ts | 33 +- app/server/modules/backups/backup.helpers.ts | 5 +- app/server/modules/backups/backups.dto.ts | 3 + app/server/modules/backups/backups.service.ts | 1 + app/server/modules/lifecycle/migrations.ts | 7 +- .../00005-split-backup-include-paths.ts | 55 + e2e/0002-backup-restore.spec.ts | 45 + .../restic/commands/__tests__/backup.test.ts | 48 +- packages/core/src/restic/commands/backup.ts | 30 +- packages/core/src/restic/commands/restore.ts | 4 +- packages/core/src/restic/restic-dto.ts | 6 +- 24 files changed, 2551 insertions(+), 72 deletions(-) create mode 100644 app/drizzle/20260320172926_hesitant_naoko/migration.sql create mode 100644 app/drizzle/20260320172926_hesitant_naoko/snapshot.json create mode 100644 app/server/modules/lifecycle/migrations/00005-split-backup-include-paths.ts diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 216a4740..48cbca56 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2430,6 +2430,7 @@ export type ListBackupSchedulesResponses = { } | null; excludePatterns: Array | null; excludeIfPresent: Array | null; + includePaths: Array | null; includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; @@ -2704,6 +2705,7 @@ export type CreateBackupScheduleData = { }; excludePatterns?: Array; excludeIfPresent?: Array; + includePaths?: Array; includePatterns?: Array; oneFileSystem?: boolean; tags?: Array; @@ -2737,6 +2739,7 @@ export type CreateBackupScheduleResponses = { } | null; excludePatterns: Array | null; excludeIfPresent: Array | null; + includePaths: Array | null; includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; @@ -2803,6 +2806,7 @@ export type GetBackupScheduleResponses = { } | null; excludePatterns: Array | null; excludeIfPresent: Array | null; + includePaths: Array | null; includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; @@ -3076,6 +3080,7 @@ export type UpdateBackupScheduleData = { }; excludePatterns?: Array; excludeIfPresent?: Array; + includePaths?: Array; includePatterns?: Array; oneFileSystem?: boolean; tags?: Array; @@ -3111,6 +3116,7 @@ export type UpdateBackupScheduleResponses = { } | null; excludePatterns: Array | null; excludeIfPresent: Array | null; + includePaths: Array | null; includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; @@ -3157,6 +3163,7 @@ export type GetBackupScheduleForVolumeResponses = { } | null; excludePatterns: Array | null; excludeIfPresent: Array | null; + includePaths: Array | null; includePatterns: Array | null; oneFileSystem: boolean; customResticParams: Array | null; @@ -4154,13 +4161,13 @@ export type GetBackupProgressResponses = { scheduleId: string; volumeName: string; repositoryName: string; - seconds_elapsed: number; + seconds_elapsed?: number; seconds_remaining?: number; - percent_done: number; - total_files: number; - files_done: number; - total_bytes: number; - bytes_done: number; + percent_done?: number; + total_files?: number; + files_done?: number; + total_bytes?: number; + bytes_done?: number; current_files?: Array; } | null; }; diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index 0e6f4140..579433f1 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -19,10 +19,19 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props) refetchInterval: 1000, }); - const percentDone = progress ? Math.round(progress.percent_done * 100) : 0; + const { + percent_done = 0, + bytes_done = 0, + total_bytes = 0, + seconds_elapsed = 0, + files_done = 0, + total_files = 0, + } = progress ?? {}; + + const percentDone = progress ? Math.round(percent_done * 100) : 0; const currentFile = progress?.current_files?.[0] || ""; const fileName = currentFile.split("/").pop() || currentFile; - const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null; + const speed = progress ? formatBytes(bytes_done / seconds_elapsed) : null; const eta = progress?.seconds_remaining ? formatDuration(progress.seconds_remaining) : null; return ( @@ -43,7 +52,7 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)

{progress ? ( <> - {progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()} + {files_done.toLocaleString()} / {total_files.toLocaleString()} ) : ( "—" @@ -55,9 +64,9 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)

{progress ? ( <> - +  /  - + ) : ( "—" @@ -66,12 +75,12 @@ export const BackupProgressCard = ({ scheduleShortId, initialProgress }: Props)

Elapsed

-

{progress ? formatDuration(progress.seconds_elapsed) : "—"}

+

{progress ? formatDuration(seconds_elapsed) : "—"}

Speed

- {progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"} + {progress ? (seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}

diff --git a/app/client/modules/backups/components/create-schedule-form/index.tsx b/app/client/modules/backups/components/create-schedule-form/index.tsx index beb040b7..949d2d2f 100644 --- a/app/client/modules/backups/components/create-schedule-form/index.tsx +++ b/app/client/modules/backups/components/create-schedule-form/index.tsx @@ -40,22 +40,22 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: const { excludePatternsText, excludeIfPresentText, - includePatternsText, + includePatterns, customResticParamsText, - includePatterns: fileBrowserPatterns, + includePaths, cronExpression, ...rest } = data; const excludePatterns = parseMultilineEntries(excludePatternsText); const excludeIfPresent = parseMultilineEntries(excludeIfPresentText); - const textPatterns = parseMultilineEntries(includePatternsText); - const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns]; + const parsedIncludePatterns = parseMultilineEntries(includePatterns); const customResticParams = parseMultilineEntries(customResticParamsText); onSubmit({ ...rest, cronExpression, - includePatterns: includePatterns.length > 0 ? includePatterns : [], + includePaths: includePaths?.length ? includePaths : [], + includePatterns: parsedIncludePatterns.length > 0 ? parsedIncludePatterns : [], excludePatterns, excludeIfPresent, customResticParams, @@ -67,13 +67,13 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: const frequency = form.watch("frequency"); const formValues = form.watch(); - const [selectedPaths, setSelectedPaths] = useState>(new Set(initialValues?.includePatterns || [])); + const [selectedPaths, setSelectedPaths] = useState>(new Set(initialValues?.includePaths || [])); const [showAllSelectedPaths, setShowAllSelectedPaths] = useState(false); const handleSelectionChange = useCallback( (paths: Set) => { setSelectedPaths(paths); - form.setValue("includePatterns", Array.from(paths)); + form.setValue("includePaths", Array.from(paths)); }, [form], ); @@ -83,7 +83,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: const newPaths = new Set(selectedPaths); newPaths.delete(pathToRemove); setSelectedPaths(newPaths); - form.setValue("includePatterns", Array.from(newPaths)); + form.setValue("includePaths", Array.from(newPaths)); }, [selectedPaths, form], ); diff --git a/app/client/modules/backups/components/create-schedule-form/paths-section.tsx b/app/client/modules/backups/components/create-schedule-form/paths-section.tsx index a84b6254..d97e4206 100644 --- a/app/client/modules/backups/components/create-schedule-form/paths-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/paths-section.tsx @@ -68,7 +68,7 @@ export const PathsSection = ({ )} ( Additional include patterns diff --git a/app/client/modules/backups/components/create-schedule-form/summary-section.tsx b/app/client/modules/backups/components/create-schedule-form/summary-section.tsx index 69872f3c..4b98846e 100644 --- a/app/client/modules/backups/components/create-schedule-form/summary-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/summary-section.tsx @@ -38,22 +38,22 @@ export const SummarySection = ({ volume, frequency, formValues }: SummarySection {repositoriesData?.find((r) => r.shortId === formValues.repositoryId)?.name || "—"}

- {(formValues.includePatterns && formValues.includePatterns.length > 0) || formValues.includePatternsText ? ( + {(formValues.includePaths && formValues.includePaths.length > 0) || formValues.includePatterns ? (

Include paths/patterns

- {formValues.includePatterns?.slice(0, 20).map((path) => ( + {formValues.includePaths?.slice(0, 20).map((path) => ( {path} ))} - {formValues.includePatterns && formValues.includePatterns.length > 20 && ( - + {formValues.includePatterns.length - 20} more + {formValues.includePaths && formValues.includePaths.length > 20 && ( + + {formValues.includePaths.length - 20} more )} - {formValues.includePatternsText + {formValues.includePatterns ?.split("\n") .filter(Boolean) - .slice(0, 20 - (formValues.includePatterns?.length || 0)) + .slice(0, 20 - (formValues.includePaths?.length || 0)) .map((pattern) => ( {pattern.trim()} diff --git a/app/client/modules/backups/components/create-schedule-form/types.ts b/app/client/modules/backups/components/create-schedule-form/types.ts index bad646ff..a1d2a4fa 100644 --- a/app/client/modules/backups/components/create-schedule-form/types.ts +++ b/app/client/modules/backups/components/create-schedule-form/types.ts @@ -5,8 +5,8 @@ export const internalFormSchema = z.object({ repositoryId: z.string(), excludePatternsText: z.string().optional(), excludeIfPresentText: z.string().optional(), - includePatternsText: z.string().optional(), - includePatterns: z.array(z.string()).optional(), + includePatterns: z.string().optional(), + includePaths: z.array(z.string()).optional(), frequency: z.string(), dailyTime: z.string().optional(), weeklyDay: z.string().optional(), @@ -36,9 +36,10 @@ export type InternalFormValues = z.infer; export type BackupScheduleFormValues = Omit< InternalFormValues, - "excludePatternsText" | "excludeIfPresentText" | "includePatternsText" | "customResticParamsText" + "excludePatternsText" | "excludeIfPresentText" | "includePatterns" | "customResticParamsText" > & { excludePatterns?: string[]; excludeIfPresent?: string[]; + includePatterns?: string[]; customResticParams?: string[]; }; diff --git a/app/client/modules/backups/components/create-schedule-form/utils.ts b/app/client/modules/backups/components/create-schedule-form/utils.ts index a2d59ccc..6584ea25 100644 --- a/app/client/modules/backups/components/create-schedule-form/utils.ts +++ b/app/client/modules/backups/components/create-schedule-form/utils.ts @@ -17,16 +17,11 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *"); - const patterns = schedule.includePatterns || []; - const isGlobPattern = (p: string) => /[*?[\]]/.test(p); - const fileBrowserPaths = patterns.filter((p) => !isGlobPattern(p)); - const textPatterns = patterns.filter(isGlobPattern); - return { name: schedule.name, repositoryId: schedule.repository.shortId, - includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, - includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined, + includePaths: schedule.includePaths?.length ? schedule.includePaths : undefined, + includePatterns: schedule.includePatterns?.length ? schedule.includePatterns.join("\n") : undefined, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined, excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, oneFileSystem: schedule.oneFileSystem ?? false, diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 7a521f90..a581a7b3 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -185,6 +185,7 @@ export function ScheduleDetailsPage(props: Props) { enabled: schedule.enabled, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + includePaths: formValues.includePaths, includePatterns: formValues.includePatterns, excludePatterns: formValues.excludePatterns, excludeIfPresent: formValues.excludeIfPresent, @@ -203,6 +204,7 @@ export function ScheduleDetailsPage(props: Props) { enabled, cronExpression: schedule.cronExpression, retentionPolicy: schedule.retentionPolicy || undefined, + includePaths: schedule.includePaths || [], includePatterns: schedule.includePatterns || [], excludePatterns: schedule.excludePatterns || [], excludeIfPresent: schedule.excludeIfPresent || [], diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 4fb4cf1f..80a34e91 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -69,6 +69,7 @@ export function CreateBackupPage() { enabled: true, cronExpression, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + includePaths: formValues.includePaths, includePatterns: formValues.includePatterns, excludePatterns: formValues.excludePatterns, excludeIfPresent: formValues.excludeIfPresent, diff --git a/app/drizzle/20260320172926_hesitant_naoko/migration.sql b/app/drizzle/20260320172926_hesitant_naoko/migration.sql new file mode 100644 index 00000000..4200ead6 --- /dev/null +++ b/app/drizzle/20260320172926_hesitant_naoko/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `backup_schedules_table` ADD `include_paths` text DEFAULT '[]'; \ No newline at end of file diff --git a/app/drizzle/20260320172926_hesitant_naoko/snapshot.json b/app/drizzle/20260320172926_hesitant_naoko/snapshot.json new file mode 100644 index 00000000..e8814169 --- /dev/null +++ b/app/drizzle/20260320172926_hesitant_naoko/snapshot.json @@ -0,0 +1,2281 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "ab5feb4c-5609-4ba7-be8c-ccde709abccc", + "prevIds": ["7cbce296-a3ec-4b25-8930-32d5796a8ced"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "app_metadata", + "entityType": "tables" + }, + { + "name": "backup_schedule_mirrors_table", + "entityType": "tables" + }, + { + "name": "backup_schedule_notifications_table", + "entityType": "tables" + }, + { + "name": "backup_schedules_table", + "entityType": "tables" + }, + { + "name": "invitation", + "entityType": "tables" + }, + { + "name": "member", + "entityType": "tables" + }, + { + "name": "notification_destinations_table", + "entityType": "tables" + }, + { + "name": "organization", + "entityType": "tables" + }, + { + "name": "repositories_table", + "entityType": "tables" + }, + { + "name": "sessions_table", + "entityType": "tables" + }, + { + "name": "sso_provider", + "entityType": "tables" + }, + { + "name": "two_factor", + "entityType": "tables" + }, + { + "name": "users_table", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "name": "volumes_table", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "app_metadata" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_status", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_copy_error", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_mirrors_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "schedule_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "destination_id", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_start", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "notify_on_success", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_warning", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "notify_on_failure", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedule_notifications_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "volume_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "repository_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "cron_expression", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "retention_policy", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "exclude_if_present", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_paths", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "include_patterns", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_status", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_backup_error", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "next_backup_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "one_file_system", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'[]'", + "generated": null, + "name": "custom_restic_params", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "sort_order", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "backup_schedules_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'pending'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "inviter_id", + "entityType": "columns", + "table": "invitation" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "member" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "member" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "notification_destinations_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logo", + "entityType": "columns", + "table": "organization" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "organization" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'auto'", + "generated": null, + "name": "compression_mode", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'unknown'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "doctor_result", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stats_updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "upload_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "upload_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "upload_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "download_limit_enabled", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "1", + "generated": null, + "name": "download_limit_value", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Mbps'", + "generated": null, + "name": "download_limit_unit", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "repositories_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ip_address", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_agent", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonated_by", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_organization_id", + "entityType": "columns", + "table": "sessions_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "issuer", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "domain", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "auto_link_matching_emails", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "oidc_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "saml_config", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "sso_provider" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backup_codes", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "two_factor" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password_hash", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "has_downloaded_restic_password", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "email_verified", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "display_username", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "two_factor_enabled", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_reason", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ban_expires", + "entityType": "columns", + "table": "users_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": true, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "short_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provisioning_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'unmounted'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "last_health_check", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "auto_remount", + "entityType": "columns", + "table": "volumes_table" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "organization_id", + "entityType": "columns", + "table": "volumes_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "account_user_id_users_table_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["schedule_id"], + "tableTo": "backup_schedules_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["destination_id"], + "tableTo": "notification_destinations_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "entityType": "fks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["volume_id"], + "tableTo": "volumes_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["repository_id"], + "tableTo": "repositories_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "backup_schedules_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "backup_schedules_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_organization_id_organization_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["inviter_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "invitation_inviter_id_users_table_id_fk", + "entityType": "fks", + "table": "invitation" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_organization_id_organization_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "member_user_id_users_table_id_fk", + "entityType": "fks", + "table": "member" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "notification_destinations_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "notification_destinations_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "repositories_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "repositories_table" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "sessions_table_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sessions_table" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_sso_provider_organization_id_organization_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_sso_provider_user_id_users_table_id_fk", + "entityType": "fks", + "table": "sso_provider" + }, + { + "columns": ["user_id"], + "tableTo": "users_table", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "two_factor_user_id_users_table_id_fk", + "entityType": "fks", + "table": "two_factor" + }, + { + "columns": ["organization_id"], + "tableTo": "organization", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "volumes_table_organization_id_organization_id_fk", + "entityType": "fks", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "destination_id"], + "nameExplicit": false, + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk", + "entityType": "pks", + "table": "backup_schedule_notifications_table" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["key"], + "nameExplicit": false, + "name": "app_metadata_pk", + "table": "app_metadata", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_pk", + "table": "backup_schedule_mirrors_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "backup_schedules_table_pk", + "table": "backup_schedules_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "invitation_pk", + "table": "invitation", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "member_pk", + "table": "member", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "notification_destinations_table_pk", + "table": "notification_destinations_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "organization_pk", + "table": "organization", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "repositories_table_pk", + "table": "repositories_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_table_pk", + "table": "sessions_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sso_provider_pk", + "table": "sso_provider", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "two_factor_pk", + "table": "two_factor", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_table_pk", + "table": "users_table", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "volumes_table_pk", + "table": "volumes_table", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "account_userId_idx", + "entityType": "indexes", + "table": "account" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_organizationId_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "invitation_email_idx", + "entityType": "indexes", + "table": "invitation" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_organizationId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "member_userId_idx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "member_org_user_uidx", + "entityType": "indexes", + "table": "member" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "organization_slug_uidx", + "entityType": "indexes", + "table": "organization" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "repositories_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "repositories_table" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "sessionsTable_userId_idx", + "entityType": "indexes", + "table": "sessions_table" + }, + { + "columns": [ + { + "value": "secret", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_secret_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "twoFactor_userId_idx", + "entityType": "indexes", + "table": "two_factor" + }, + { + "columns": [ + { + "value": "identifier", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "verification_identifier_idx", + "entityType": "indexes", + "table": "verification" + }, + { + "columns": [ + { + "value": "organization_id", + "isExpression": false + }, + { + "value": "provisioning_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "volumes_table_org_provisioning_id_uidx", + "entityType": "indexes", + "table": "volumes_table" + }, + { + "columns": ["schedule_id", "repository_id"], + "nameExplicit": false, + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "entityType": "uniques", + "table": "backup_schedule_mirrors_table" + }, + { + "columns": ["name", "organization_id"], + "nameExplicit": false, + "name": "volumes_table_name_organization_id_unique", + "entityType": "uniques", + "table": "volumes_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "backup_schedules_table_short_id_unique", + "entityType": "uniques", + "table": "backup_schedules_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "repositories_table_short_id_unique", + "entityType": "uniques", + "table": "repositories_table" + }, + { + "columns": ["token"], + "nameExplicit": false, + "name": "sessions_table_token_unique", + "entityType": "uniques", + "table": "sessions_table" + }, + { + "columns": ["provider_id"], + "nameExplicit": false, + "name": "sso_provider_provider_id_unique", + "entityType": "uniques", + "table": "sso_provider" + }, + { + "columns": ["username"], + "nameExplicit": false, + "name": "users_table_username_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["email"], + "nameExplicit": false, + "name": "users_table_email_unique", + "entityType": "uniques", + "table": "users_table" + }, + { + "columns": ["short_id"], + "nameExplicit": false, + "name": "volumes_table_short_id_unique", + "entityType": "uniques", + "table": "volumes_table" + } + ], + "renames": [] +} diff --git a/app/schemas/events-dto.ts b/app/schemas/events-dto.ts index 8edc3385..9acb7ab8 100644 --- a/app/schemas/events-dto.ts +++ b/app/schemas/events-dto.ts @@ -27,12 +27,12 @@ const dumpStartedEventSchema = z.object({ }); const restoreProgressMetricsSchema = z.object({ - seconds_elapsed: z.number(), - percent_done: z.number(), - total_files: z.number(), - files_restored: z.number(), - total_bytes: z.number(), - bytes_restored: z.number(), + seconds_elapsed: z.number().default(0), + percent_done: z.number().default(0), + total_files: z.number().default(0), + files_restored: z.number().default(0), + total_bytes: z.number().default(0), + bytes_restored: z.number().default(0), }); export const backupStartedEventSchema = backupEventBaseSchema; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 6b7f72db..70b62ab5 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -300,6 +300,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { }>(), excludePatterns: text("exclude_patterns", { mode: "json" }).$type().default([]), excludeIfPresent: text("exclude_if_present", { mode: "json" }).$type().default([]), + includePaths: text("include_paths", { mode: "json" }).$type().default([]), includePatterns: text("include_patterns", { mode: "json" }).$type().default([]), lastBackupAt: int("last_backup_at", { mode: "number" }), lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning" | null>(), diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts index a276a1bb..2c7f886d 100644 --- a/app/server/modules/backups/__tests__/backups.patterns.test.ts +++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts @@ -9,6 +9,7 @@ const createSchedule = (overrides: Partial = {}): BackupSch fromAny({ shortId: "sched-1234", oneFileSystem: false, + includePaths: [], includePatterns: [], excludePatterns: [], excludeIfPresent: [], @@ -20,7 +21,8 @@ describe("executeBackup - include / exclude patterns", () => { // arrange const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; const schedule = createSchedule({ - includePatterns: ["*.zip", "/Photos", "!/Temp", "!*.log"], + includePaths: ["/Photos"], + includePatterns: ["*.zip", "!/Temp", "!*.log"], excludePatterns: [".DS_Store", "/Config", "!/Important", "!*.tmp"], excludeIfPresent: [".nobackup"], }); @@ -31,9 +33,9 @@ describe("executeBackup - include / exclude patterns", () => { // assert expect(options).toMatchObject({ - include: [ + includePaths: [path.join(volumePath, "Photos")], + includePatterns: [ path.join(volumePath, "*.zip"), - path.join(volumePath, "Photos"), `!${path.join(volumePath, "Temp")}`, `!${path.join(volumePath, "*.log")}`, ], @@ -48,7 +50,7 @@ describe("executeBackup - include / exclude patterns", () => { const volumePath = `/${volumeName}`; const selectedPath = `/${volumeName}`; const schedule = createSchedule({ - includePatterns: [selectedPath], + includePaths: [selectedPath], }); const signal = new AbortController().signal; @@ -56,7 +58,7 @@ describe("executeBackup - include / exclude patterns", () => { const options = createBackupOptions(schedule, volumePath, signal); // assert - expect(options.include).toEqual([path.join(volumePath, volumeName)]); + expect(options.includePaths).toEqual([path.join(volumePath, volumeName)]); }); test("should correctly mix relative and absolute patterns", () => { @@ -73,7 +75,7 @@ describe("executeBackup - include / exclude patterns", () => { const options = createBackupOptions(schedule, volumePath, signal); // assert - expect(options.include).toEqual([ + expect(options.includePatterns).toEqual([ path.join(volumePath, relativeInclude), path.join(volumePath, "anchored/include"), ]); @@ -91,7 +93,8 @@ describe("executeBackup - include / exclude patterns", () => { const options = createBackupOptions(schedule, "/var/lib/zerobyte/volumes/vol999/_data", signal); // assert - expect(options.include).toEqual([]); + expect(options.includePaths).toEqual([]); + expect(options.includePatterns).toEqual([]); expect(options.exclude).toEqual([]); }); @@ -124,10 +127,24 @@ describe("executeBackup - include / exclude patterns", () => { const options = createBackupOptions(schedule, volumePath, signal); - expect(options.include).toEqual([ + expect(options.includePatterns).toEqual([ path.join(volumePath, "**/*.xyz"), path.join(volumePath, "*.zip"), `!${path.join(volumePath, "**/*.tmp")}`, ]); }); + + test("keeps selected include paths separate from include patterns", () => { + const volumePath = "/var/lib/zerobyte/volumes/vol123/_data"; + const schedule = createSchedule({ + includePaths: ["/movies [1]"], + includePatterns: ["**/*.txt"], + }); + const signal = new AbortController().signal; + + const options = createBackupOptions(schedule, volumePath, signal); + + expect(options.includePaths).toEqual([path.join(volumePath, "movies [1]")]); + expect(options.includePatterns).toEqual([path.join(volumePath, "**/*.txt")]); + }); }); diff --git a/app/server/modules/backups/backup.helpers.ts b/app/server/modules/backups/backup.helpers.ts index f3b05ee8..cc99dfbb 100644 --- a/app/server/modules/backups/backup.helpers.ts +++ b/app/server/modules/backups/backup.helpers.ts @@ -53,7 +53,10 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string signal, exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined, excludeIfPresent: schedule.excludeIfPresent ?? undefined, - include: schedule.includePatterns + includePaths: schedule.includePaths + ? schedule.includePaths.map((p) => processPattern(p, volumePath, true)) + : undefined, + includePatterns: schedule.includePatterns ? schedule.includePatterns.map((p) => processPattern(p, volumePath, true)) : undefined, customResticParams: schedule.customResticParams ?? undefined, diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index 5cd08fa6..bfb352b8 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -27,6 +27,7 @@ const backupScheduleSchema = z.object({ retentionPolicy: retentionPolicySchema.nullable(), excludePatterns: z.array(z.string()).nullable(), excludeIfPresent: z.array(z.string()).nullable(), + includePaths: z.array(z.string()).nullable(), includePatterns: z.array(z.string()).nullable(), oneFileSystem: z.boolean(), customResticParams: z.array(z.string()).nullable(), @@ -122,6 +123,7 @@ export const createBackupScheduleBody = z.object({ retentionPolicy: retentionPolicySchema.optional(), excludePatterns: z.array(z.string()).optional(), excludeIfPresent: z.array(z.string()).optional(), + includePaths: z.array(z.string()).optional(), includePatterns: z.array(z.string()).optional(), oneFileSystem: z.boolean().optional(), tags: z.array(z.string()).optional(), @@ -158,6 +160,7 @@ export const updateBackupScheduleBody = z.object({ retentionPolicy: retentionPolicySchema.optional(), excludePatterns: z.array(z.string()).optional(), excludeIfPresent: z.array(z.string()).optional(), + includePaths: z.array(z.string()).optional(), includePatterns: z.array(z.string()).optional(), oneFileSystem: z.boolean().optional(), tags: z.array(z.string()).optional(), diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 1c14080f..ff2571eb 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -147,6 +147,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { retentionPolicy: data.retentionPolicy ?? null, excludePatterns: data.excludePatterns ?? [], excludeIfPresent: data.excludeIfPresent ?? [], + includePaths: data.includePaths ?? [], includePatterns: data.includePatterns ?? [], oneFileSystem: data.oneFileSystem, customResticParams: data.customResticParams ?? [], diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts index 9064b8ab..5dfde9c4 100644 --- a/app/server/modules/lifecycle/migrations.ts +++ b/app/server/modules/lifecycle/migrations.ts @@ -3,6 +3,7 @@ import { v00001 } from "./migrations/00001-retag-snapshots"; import { v00002 } from "./migrations/00002-isolate-restic-passwords"; import { v00003 } from "./migrations/00003-assign-organization"; import { v00004 } from "./migrations/00004-concat-path-name"; +import { v00005 } from "./migrations/00005-split-backup-include-paths"; import { sql } from "drizzle-orm"; import { appMetadataTable, usersTable } from "../../db/schema"; import { db } from "../../db/db"; @@ -37,7 +38,7 @@ type MigrationEntity = { dependsOn?: string[]; }; -const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004]; +const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005]; export const runMigrations = async () => { const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); @@ -77,7 +78,7 @@ export const runMigrations = async () => { "Seek support by opening an issue on the Zerobyte GitHub repository if you need assistance.", "================================================================================", ]; - err.forEach((line) => logger.error(line)); + err.forEach(logger.error); process.exit(1); } } @@ -105,7 +106,7 @@ export const runMigrations = async () => { "on the Zerobyte GitHub repository if you need assistance.", "================================================================================", ]; - err.forEach((line) => logger.error(line)); + err.forEach(logger.error); process.exit(1); } } diff --git a/app/server/modules/lifecycle/migrations/00005-split-backup-include-paths.ts b/app/server/modules/lifecycle/migrations/00005-split-backup-include-paths.ts new file mode 100644 index 00000000..39874b67 --- /dev/null +++ b/app/server/modules/lifecycle/migrations/00005-split-backup-include-paths.ts @@ -0,0 +1,55 @@ +import { eq } from "drizzle-orm"; +import { logger } from "@zerobyte/core/node"; +import { db } from "../../../db/db"; +import { backupSchedulesTable } from "../../../db/schema"; +import { toMessage } from "~/server/utils/errors"; + +export const isIncludePatternEntry = (value: string) => value.startsWith("!") || /[*?[\]]/.test(value); + +const execute = async () => { + const errors: Array<{ name: string; error: string }> = []; + const schedules = await db.query.backupSchedulesTable.findMany(); + let migratedCount = 0; + + for (const schedule of schedules) { + if (schedule.includePaths?.length || !schedule.includePatterns?.length) { + continue; + } + + try { + const existingIncludePatterns = schedule.includePatterns ?? []; + const includePaths = existingIncludePatterns.filter((value) => !isIncludePatternEntry(value)); + if (includePaths.length === 0) { + continue; + } + + const includePatterns = existingIncludePatterns.filter(isIncludePatternEntry); + + await db + .update(backupSchedulesTable) + .set({ + includePaths, + includePatterns, + updatedAt: Date.now(), + }) + .where(eq(backupSchedulesTable.id, schedule.id)); + + migratedCount += 1; + } catch (error) { + errors.push({ + name: `backup-schedule:${schedule.id}`, + error: toMessage(error), + }); + } + } + + logger.info(`Migration 00005-split-backup-include-paths updated ${migratedCount} backup schedules.`); + + return { success: errors.length === 0, errors }; +}; + +export const v00005 = { + execute, + id: "00005-split-backup-include-paths", + type: "maintenance" as const, +}; diff --git a/e2e/0002-backup-restore.spec.ts b/e2e/0002-backup-restore.spec.ts index 20ab7555..817c4061 100644 --- a/e2e/0002-backup-restore.spec.ts +++ b/e2e/0002-backup-restore.spec.ts @@ -16,6 +16,7 @@ type ScenarioOptions = { includePatterns?: string; excludePatterns?: string; excludeIfPresent?: string; + selectedPaths?: string[]; }; type BackupJobOptions = ScenarioOptions & { @@ -99,6 +100,15 @@ async function createBackupJob(page: Page, options: BackupJobOptions) { if (options.includePatterns) { await page.getByLabel("Additional include patterns").fill(options.includePatterns); } + if (options.selectedPaths) { + for (const selectedPath of options.selectedPaths) { + const escapedPath = path.posix.basename(selectedPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await page + .getByRole("button", { name: new RegExp(escapedPath) }) + .getByRole("checkbox") + .click(); + } + } if (options.excludePatterns) { await page.getByLabel("Exclusion patterns").fill(options.excludePatterns); } @@ -462,3 +472,38 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" await expect(page.getByRole("button", { name: /\.nobackup/ })).toBeVisible(); await expect(page.getByRole("button", { name: /blocked\.xyz/ })).toHaveCount(0); }); + +test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => { + const runId = getRunId(testInfo); + const names = getScenarioNames(runId); + const bracketDir = `movies [${runId}]`; + const bracketPath = path.join(testDataPath, bracketDir); + const fileName = `inside-${runId}.txt`; + + fs.mkdirSync(bracketPath, { recursive: true }); + fs.writeFileSync(path.join(bracketPath, fileName), "bracket path content"); + + await gotoAndWaitForAppReady(page, "/"); + await expect(page).toHaveURL("/volumes"); + + await createBackupScenario(page, names, { + selectedPaths: [`/${bracketDir}`], + }); + + await page.getByRole("button", { name: "Backup now" }).click(); + await expect(page.getByText("Backup started successfully")).toBeVisible(); + await expect(page.getByText("✓ Success")).toBeVisible({ timeout: 30000 }); + + await page + .getByRole("button", { name: /\d+ B$/ }) + .first() + .click(); + const bracketFolderRow = page.getByRole("button", { + name: new RegExp(bracketDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + }); + await expect(bracketFolderRow).toBeVisible(); + await bracketFolderRow.locator("svg").first().click(); + await expect(page.getByRole("button", { name: new RegExp(fileName.replace(".", "\\.")) })).toBeVisible({ + timeout: 15000, + }); +}); diff --git a/packages/core/src/restic/commands/__tests__/backup.test.ts b/packages/core/src/restic/commands/__tests__/backup.test.ts index fdd0e14b..476c253f 100644 --- a/packages/core/src/restic/commands/__tests__/backup.test.ts +++ b/packages/core/src/restic/commands/__tests__/backup.test.ts @@ -51,7 +51,7 @@ const config = { type SetupOptions = { spawnResult?: Partial; - onSpawnCall?: (params: SafeSpawnParams) => void; + onSpawnCall?: (params: SafeSpawnParams) => void | Promise; }; /** @@ -64,8 +64,12 @@ const setup = ({ spawnResult = {}, onSpawnCall }: SetupOptions = {}) => { const cleanupSpy = spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve()); spyOn(spawnModule, "safeSpawn").mockImplementation((params) => { capturedArgs = params.args; - onSpawnCall?.(params); - return Promise.resolve({ exitCode: 0, summary: VALID_SUMMARY, error: "", ...spawnResult }); + return Promise.resolve(onSpawnCall?.(params)).then(() => ({ + exitCode: 0, + summary: VALID_SUMMARY, + error: "", + ...spawnResult, + })); }); return { @@ -117,7 +121,7 @@ describe("backup command", () => { "/mnt/data", { organizationId: "org-1", - include: ["/mnt/data/docs", "/mnt/data/photos"], + includePatterns: ["/mnt/data/docs", "/mnt/data/photos"], }, mockDeps, ); @@ -126,6 +130,42 @@ describe("backup command", () => { expect(getArgs()).not.toContain("/mnt/data"); }); + test("passes include paths via --files-from-raw and include patterns via --files-from", async () => { + const literalDir = "/mnt/data/movies [1]"; + let rawIncludeContent = ""; + let patternIncludeContent = ""; + const { hasFlag } = setup({ + onSpawnCall: async (params) => { + const rawIncludeIndex = params.args.indexOf("--files-from-raw"); + const patternIncludeIndex = params.args.indexOf("--files-from"); + + if (rawIncludeIndex > -1) { + rawIncludeContent = await Bun.file(params.args[rawIncludeIndex + 1]!).text(); + } + + if (patternIncludeIndex > -1) { + patternIncludeContent = await Bun.file(params.args[patternIncludeIndex + 1]!).text(); + } + }, + }); + + await backup( + config, + "/mnt/data", + { + organizationId: "org-1", + includePaths: [literalDir], + includePatterns: ["/mnt/data/**/*.zip"], + }, + mockDeps, + ); + + expect(hasFlag("--files-from-raw")).toBe(true); + expect(hasFlag("--files-from")).toBe(true); + expect(rawIncludeContent).toBe(`${literalDir}\0`); + expect(patternIncludeContent).toBe("/mnt/data/**/*.zip"); + }); + test("adds --tag for each entry in options.tags", async () => { const { getOptionValues } = setup(); await backup( diff --git a/packages/core/src/restic/commands/backup.ts b/packages/core/src/restic/commands/backup.ts index b0022db8..1a9078e1 100644 --- a/packages/core/src/restic/commands/backup.ts +++ b/packages/core/src/restic/commands/backup.ts @@ -20,7 +20,8 @@ export const backup = async ( organizationId: string; exclude?: string[]; excludeIfPresent?: string[]; - include?: string[]; + includePaths?: string[]; + includePatterns?: string[]; tags?: string[]; oneFileSystem?: boolean; compressionMode?: CompressionMode; @@ -50,23 +51,35 @@ export const backup = async ( } let includeFile: string | null = null; - const usesSourceArg = !options.include || options.include.length === 0; + let rawIncludeFile: string | null = null; + const usesSourceArg = + (!options.includePaths || options.includePaths.length === 0) && + (!options.includePatterns || options.includePatterns.length === 0); - if (options.include && options.include.length > 0) { + if (options.includePatterns?.length) { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-")); includeFile = path.join(tmp, "include.txt"); - await fs.writeFile(includeFile, options.include.join("\n"), "utf-8"); + await fs.writeFile(includeFile, options.includePatterns.join("\n"), "utf-8"); args.push("--files-from", includeFile); } + if (options.includePaths?.length) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-raw-")); + rawIncludeFile = path.join(tmp, "include.raw"); + + await fs.writeFile(rawIncludeFile, Buffer.from(`${options.includePaths.join("\0")}\0`, "utf-8")); + + args.push("--files-from-raw", rawIncludeFile); + } + for (const exclude of deps.defaultExcludes) { args.push("--exclude", exclude); } let excludeFile: string | null = null; - if (options.exclude && options.exclude.length > 0) { + if (options.exclude?.length) { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-")); excludeFile = path.join(tmp, "exclude.txt"); @@ -75,13 +88,13 @@ export const backup = async ( args.push("--exclude-file", excludeFile); } - if (options.excludeIfPresent && options.excludeIfPresent.length > 0) { + if (options.excludeIfPresent?.length) { for (const filename of options.excludeIfPresent) { args.push("--exclude-if-present", filename); } } - if (options.customResticParams && options.customResticParams.length > 0) { + if (options.customResticParams?.length) { const validationError = validateCustomResticParams(options.customResticParams); if (validationError) { throw new Error(`Invalid customResticParams: ${validationError}`); @@ -147,6 +160,9 @@ export const backup = async ( if (includeFile) { await fs.unlink(includeFile).catch(() => {}); } + if (rawIncludeFile) { + await fs.unlink(rawIncludeFile).catch(() => {}); + } if (excludeFile) { await fs.unlink(excludeFile).catch(() => {}); } diff --git a/packages/core/src/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts index df41d7be..8788f0c1 100644 --- a/packages/core/src/restic/commands/restore.ts +++ b/packages/core/src/restic/commands/restore.ts @@ -14,9 +14,9 @@ import type { ResticDeps } from "../types"; const restoreProgressSchema = z.object({ message_type: z.enum(["status", "summary"]), - seconds_elapsed: z.number(), + seconds_elapsed: z.number().default(0), percent_done: z.number().default(0), - total_files: z.number(), + total_files: z.number().default(0), files_restored: z.number().default(0), total_bytes: z.number().default(0), bytes_restored: z.number().default(0), diff --git a/packages/core/src/restic/restic-dto.ts b/packages/core/src/restic/restic-dto.ts index 702596a8..2d3b36bf 100644 --- a/packages/core/src/restic/restic-dto.ts +++ b/packages/core/src/restic/restic-dto.ts @@ -32,10 +32,10 @@ export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({ export const resticBackupProgressMetricsSchema = z.object({ seconds_elapsed: z.number().default(0), seconds_remaining: z.number().default(0), - percent_done: z.number(), - total_files: z.number(), + percent_done: z.number().default(0), + total_files: z.number().default(0), files_done: z.number().default(0), - total_bytes: z.number(), + total_bytes: z.number().default(0), bytes_done: z.number().default(0), current_files: z.array(z.string()).default([]), });