refactor: tag snapshots with schedule short_id instead of db id

This commit is contained in:
Nicolas Meienberger 2025-12-26 21:26:47 +01:00 committed by Nico
parent 54068d5269
commit c712836629
20 changed files with 1811 additions and 162 deletions

View file

@ -1390,6 +1390,7 @@ export type ListBackupSchedulesResponses = {
keepWithinDuration?: string;
keepYearly?: number;
} | null;
shortId: string;
updatedAt: number;
volume: {
autoRemount: boolean;
@ -1500,6 +1501,7 @@ export type CreateBackupScheduleResponses = {
keepWithinDuration?: string;
keepYearly?: number;
} | null;
shortId: string;
updatedAt: number;
volumeId: number;
};
@ -1637,6 +1639,7 @@ export type GetBackupScheduleResponses = {
keepWithinDuration?: string;
keepYearly?: number;
} | null;
shortId: string;
updatedAt: number;
volume: {
autoRemount: boolean;
@ -1748,6 +1751,7 @@ export type UpdateBackupScheduleResponses = {
keepWithinDuration?: string;
keepYearly?: number;
} | null;
shortId: string;
updatedAt: number;
volumeId: number;
};
@ -1865,6 +1869,7 @@ export type GetBackupScheduleForVolumeResponses = {
keepWithinDuration?: string;
keepYearly?: number;
} | null;
shortId: string;
updatedAt: number;
volume: {
autoRemount: boolean;

View file

@ -85,8 +85,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => {
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
const backup = backups.find((b) => backupIds.includes(b.id));
const backup = backups.find((b) => snapshot.tags.includes(b.shortId));
return (
<TableRow

View file

@ -80,7 +80,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
isLoading,
failureReason,
} = useQuery({
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.id.toString() } }),
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.shortId } }),
});
const updateSchedule = useMutation({

View file

@ -116,8 +116,7 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
{(value) => {
if (!value.data) return null;
const backupIds = value.data.tags.map(Number).filter((tag) => !Number.isNaN(tag));
const backupSchedule = schedules.data?.find((s) => backupIds.includes(s.id));
const backupSchedule = schedules.data?.find((s) => value.data.tags.includes(s.shortId));
return (
<>

View file

@ -29,8 +29,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
const backup = schedules.data?.find((b) => backupIds.includes(b.id));
const backup = schedules.data?.find((b) => snapshot.tags.includes(b.shortId));
return (
snapshot.short_id.toLowerCase().includes(searchLower) ||

View file

@ -0,0 +1,3 @@
ALTER TABLE `backup_schedules_table` ADD `short_id` text;--> statement-breakpoint
UPDATE `backup_schedules_table` SET `short_id` = lower(hex(randomblob(4))) WHERE `short_id` IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `backup_schedules_table_short_id_unique` ON `backup_schedules_table` (`short_id`);

View file

@ -0,0 +1,31 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_backup_schedules_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`short_id` text NOT NULL,
`name` text NOT NULL,
`volume_id` integer NOT NULL,
`repository_id` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`cron_expression` text NOT NULL,
`retention_policy` text,
`exclude_patterns` text DEFAULT '[]',
`exclude_if_present` text DEFAULT '[]',
`include_patterns` text DEFAULT '[]',
`last_backup_at` integer,
`last_backup_status` text,
`last_backup_error` text,
`next_backup_at` integer,
`one_file_system` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_backup_schedules_table`("id", "short_id", "name", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "exclude_if_present", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "one_file_system", "sort_order", "created_at", "updated_at") SELECT "id", "short_id", "name", "volume_id", "repository_id", "enabled", "cron_expression", "retention_policy", "exclude_patterns", "exclude_if_present", "include_patterns", "last_backup_at", "last_backup_status", "last_backup_error", "next_backup_at", "one_file_system", "sort_order", "created_at", "updated_at" FROM `backup_schedules_table`;--> statement-breakpoint
DROP TABLE `backup_schedules_table`;--> statement-breakpoint
ALTER TABLE `__new_backup_schedules_table` RENAME TO `backup_schedules_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `backup_schedules_table_short_id_unique` ON `backup_schedules_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `backup_schedules_table_name_unique` ON `backup_schedules_table` (`name`);

View file

@ -0,0 +1,846 @@
{
"version": "6",
"dialect": "sqlite",
"id": "94ebc8c3-f11f-48cb-9814-d8d550422d26",
"prevId": "19421265-4e3a-46b8-9ca1-a01d1e293dbc",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_mirrors_table": {
"name": "backup_schedule_mirrors_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_copy_at": {
"name": "last_copy_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_status": {
"name": "last_copy_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_error": {
"name": "last_copy_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
"columns": [
"schedule_id",
"repository_id"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_warning": {
"name": "notify_on_warning",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table",
"columnsFrom": [
"destination_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"one_file_system": {
"name": "one_file_system",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedules_table_short_id_unique": {
"name": "backup_schedules_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"backup_schedules_table_name_unique": {
"name": "backup_schedules_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "volumes_table",
"columnsFrom": [
"volume_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,846 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2837bed4-34fb-4d16-b331-7b6d483979bc",
"prevId": "94ebc8c3-f11f-48cb-9814-d8d550422d26",
"tables": {
"app_metadata": {
"name": "app_metadata",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_mirrors_table": {
"name": "backup_schedule_mirrors_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_copy_at": {
"name": "last_copy_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_status": {
"name": "last_copy_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_copy_error": {
"name": "last_copy_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
"columns": [
"schedule_id",
"repository_id"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedule_mirrors_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedule_notifications_table": {
"name": "backup_schedule_notifications_table",
"columns": {
"schedule_id": {
"name": "schedule_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"destination_id": {
"name": "destination_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notify_on_start": {
"name": "notify_on_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_success": {
"name": "notify_on_success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notify_on_warning": {
"name": "notify_on_warning",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"notify_on_failure": {
"name": "notify_on_failure",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table",
"columnsFrom": [
"schedule_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
"tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table",
"columnsFrom": [
"destination_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"backup_schedules_table": {
"name": "backup_schedules_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"volume_id": {
"name": "volume_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"retention_policy": {
"name": "retention_policy",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exclude_patterns": {
"name": "exclude_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"include_patterns": {
"name": "include_patterns",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"last_backup_at": {
"name": "last_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_status": {
"name": "last_backup_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_backup_error": {
"name": "last_backup_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"next_backup_at": {
"name": "next_backup_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"one_file_system": {
"name": "one_file_system",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"backup_schedules_table_short_id_unique": {
"name": "backup_schedules_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"backup_schedules_table_name_unique": {
"name": "backup_schedules_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "volumes_table",
"columnsFrom": [
"volume_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"tableTo": "repositories_table",
"columnsFrom": [
"repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notification_destinations_table": {
"name": "notification_destinations_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"repositories_table": {
"name": "repositories_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compression_mode": {
"name": "compression_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'auto'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'unknown'"
},
"last_checked": {
"name": "last_checked",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"repositories_table_short_id_unique": {
"name": "repositories_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions_table": {
"name": "sessions_table",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"tableTo": "users_table",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_table": {
"name": "users_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"has_downloaded_restic_password": {
"name": "has_downloaded_restic_password",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"users_table_username_unique": {
"name": "users_table_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"volumes_table": {
"name": "volumes_table",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"short_id": {
"name": "short_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'unmounted'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_health_check": {
"name": "last_health_check",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auto_remount": {
"name": "auto_remount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {
"volumes_table_short_id_unique": {
"name": "volumes_table_short_id_unique",
"columns": [
"short_id"
],
"isUnique": true
},
"volumes_table_name_unique": {
"name": "volumes_table_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -190,6 +190,20 @@
"when": 1766765013108,
"tag": "0026_migrate-local-repo-paths",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1766778073418,
"tag": "0027_careful_cammi",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1766778162985,
"tag": "0028_third_amazoness",
"breakpoints": true
}
]
}

View file

@ -6,4 +6,4 @@ export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
export const REQUIRED_MIGRATIONS = ["v0.14.0"];
export const REQUIRED_MIGRATIONS = ["v0.21.0"];

View file

@ -69,6 +69,7 @@ export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
*/
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
volumeId: int("volume_id")
.notNull()

View file

@ -1,7 +1,7 @@
import { createHonoServer } from "react-router-hono-server/bun";
import { runDbMigrations } from "./db/db";
import { startup } from "./modules/lifecycle/startup";
import { migrateToShortIds } from "./modules/lifecycle/migration";
import { retagSnapshots } from "./modules/lifecycle/migration";
import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
import { REQUIRED_MIGRATIONS } from "./core/constants";
@ -13,7 +13,7 @@ const app = createApp();
runDbMigrations();
await migrateToShortIds();
await retagSnapshots();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
startup();

View file

@ -17,6 +17,7 @@ export type RetentionPolicy = typeof retentionPolicySchema.infer;
const backupScheduleSchema = type({
id: "number",
shortId: "string",
name: "string",
volumeId: "number",
repositoryId: "string",

View file

@ -14,6 +14,7 @@ import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
import path from "node:path";
import { generateShortId } from "~/server/utils/id";
const runningBackups = new Map<number, AbortController>();
@ -126,6 +127,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem,
nextBackupAt: nextBackupAt,
shortId: generateShortId(),
})
.returning();
@ -277,7 +279,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
oneFileSystem?: boolean;
signal?: AbortSignal;
} = {
tags: [schedule.id.toString()],
tags: [schedule.shortId],
oneFileSystem: schedule.oneFileSystem,
signal: abortController.signal,
};
@ -476,7 +478,7 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId });
} finally {
releaseLock();
}
@ -570,6 +572,14 @@ const copyToMirrors = async (
sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] },
retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"],
) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
with: { repository: true },
@ -599,7 +609,7 @@ const copyToMirrors = async (
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
try {
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: scheduleId.toString() });
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId });
} finally {
releaseSource();
releaseMirror();

View file

@ -1,14 +1,13 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { VOLUME_MOUNT_BASE, REPOSITORY_BASE } from "../../core/constants";
import { logger } from "../../utils/logger";
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
import type { RepositoryConfig } from "~/schemas/restic";
import { toMessage } from "~/server/utils/errors";
import { safeSpawn } from "~/server/utils/spawn";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
const MIGRATION_VERSION = "v0.14.0";
const MIGRATION_VERSION = "v0.21.0";
interface MigrationResult {
success: boolean;
@ -28,19 +27,17 @@ export class MigrationError extends Error {
}
}
export const migrateToShortIds = async () => {
export const retagSnapshots = async () => {
const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION);
if (alreadyMigrated) {
logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`);
return;
}
logger.info(`Starting short ID migration (${MIGRATION_VERSION})...`);
logger.info(`Starting snapshots retagging migration (${MIGRATION_VERSION})...`);
const volumeResult = await migrateVolumeFolders();
const repoResult = await migrateRepositoryFolders();
const allErrors = [...volumeResult.errors, ...repoResult.errors];
const result = await migrateSnapshotsToShortIdTag();
const allErrors = [...result.errors];
if (allErrors.length > 0) {
for (const err of allErrors) {
@ -51,148 +48,45 @@ export const migrateToShortIds = async () => {
await recordMigrationCheckpoint(MIGRATION_VERSION);
logger.info(`Short ID migration (${MIGRATION_VERSION}) complete.`);
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
};
const migrateVolumeFolders = async (): Promise<MigrationResult> => {
const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
const errors: Array<{ name: string; error: string }> = [];
const volumes = await db.query.volumesTable.findMany({});
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
for (const volume of volumes) {
if (volume.config.backend === "directory") {
for (const schedule of backupSchedules) {
const oldTag = schedule.id.toString();
const newTag = schedule.shortId;
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, schedule.repositoryId),
});
if (!repository) {
errors.push({ name: `schedule:${schedule.name}`, error: `Associated repository not found` });
continue;
}
const oldPath = path.join(VOLUME_MOUNT_BASE, volume.name);
const newPath = path.join(VOLUME_MOUNT_BASE, volume.shortId);
const repoUrl = buildRepoUrl(repository.config);
const env = await buildEnv(repository.config);
const oldExists = await pathExists(oldPath);
const newExists = await pathExists(newPath);
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
if (oldExists && !newExists) {
try {
logger.info(`Migrating volume folder: ${oldPath} -> ${newPath}`);
await fs.rename(oldPath, newPath);
logger.info(`Successfully migrated volume folder for "${volume.name}"`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `volume:${volume.name}`, error: errorMessage });
}
} else if (oldExists && newExists) {
logger.warn(
`Both old (${oldPath}) and new (${newPath}) paths exist for volume "${volume.name}". Manual intervention may be required.`,
);
addCommonArgs(args, env);
logger.info(`Migrating snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(repository.config, env);
if (res.exitCode !== 0) {
logger.error(`Restic tag failed: ${res.stderr}`);
errors.push({ name: `schedule:${schedule.name}`, error: `Restic tag command failed: ${toMessage(res.stderr)}` });
continue;
}
logger.info(`Migrated snapshots for schedule '${schedule.name}' from tag '${oldTag}' to '${newTag}'`);
}
return { success: errors.length === 0, errors };
};
const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
const errors: Array<{ name: string; error: string }> = [];
const repositories = await db.query.repositoriesTable.findMany({});
for (const repo of repositories) {
if (repo.config.backend !== "local") {
continue;
}
const config = repo.config as Extract<RepositoryConfig, { backend: "local" }>;
if (config.isExistingRepository) {
logger.debug(`Skipping imported repository "${repo.name}" - folder path is user-defined`);
continue;
}
if (config.name === repo.shortId) {
continue;
}
const basePath = config.path || REPOSITORY_BASE;
const oldPath = path.join(basePath, config.name);
const newPath = path.join(basePath, repo.shortId);
const oldExists = await pathExists(oldPath);
const newExists = await pathExists(newPath);
if (oldExists && !newExists) {
try {
logger.info(`Migrating repository folder: ${oldPath} -> ${newPath}`);
await fs.rename(oldPath, newPath);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
logger.info(`Successfully migrated repository folder and config for "${repo.name}"`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
} else if (oldExists && newExists) {
logger.warn(
`Both old (${oldPath}) and new (${newPath}) paths exist for repository "${repo.name}". Manual intervention may be required.`,
);
} else if (!oldExists && !newExists) {
try {
logger.info(`Updating config.name for repository "${repo.name}" (no folder exists yet)`);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
} else if (newExists && !oldExists && config.name !== repo.shortId) {
try {
logger.info(`Folder already at new path, updating config.name for repository "${repo.name}"`);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
}
}
return { success: errors.length === 0, errors };
};
const pathExists = async (p: string): Promise<boolean> => {
try {
await fs.access(p);
return true;
} catch {
return false;
}
};

View file

@ -161,7 +161,7 @@ const listSnapshots = async (id: string, backupId?: string) => {
let snapshots = [];
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
snapshots = await restic.snapshots(repository.config, { tags: [backupId] });
} else {
snapshots = await restic.snapshots(repository.config);
}

View file

@ -1,6 +1,6 @@
import crypto from "node:crypto";
export const generateShortId = (length = 5): string => {
export const generateShortId = (length = 8): string => {
const bytesNeeded = Math.ceil((length * 3) / 4);
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length).toLowerCase();
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
};

View file

@ -72,7 +72,7 @@ const ensurePassfile = async () => {
}
};
const buildRepoUrl = (config: RepositoryConfig): string => {
export const buildRepoUrl = (config: RepositoryConfig): string => {
switch (config.backend) {
case "local":
if (config.isExistingRepository) {
@ -105,7 +105,7 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
}
};
const buildEnv = async (config: RepositoryConfig) => {
export const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/var/lib/zerobyte/restic/cache",
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
@ -805,7 +805,7 @@ const copy = async (
};
};
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
@ -815,7 +815,7 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string
}
};
const addCommonArgs = (args: string[], env: Record<string, string>) => {
export const addCommonArgs = (args: string[], env: Record<string, string>) => {
args.push("--json");
if (env._SFTP_SSH_ARGS) {

View file

@ -8,6 +8,7 @@ export const createTestBackupSchedule = async (overrides: Partial<BackupSchedule
cronExpression: "0 0 * * *",
repositoryId: "repo_123",
volumeId: 1,
shortId: faker.string.uuid(),
...overrides,
};