From b26a062648ae502e816eb2f9082e18f179406b20 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:47:09 +0100 Subject: [PATCH] refactor: use short ids to allow changing the name of volumes & repos (#67) * refactor: use short ids to allow changing the name of volumes & repos * refactor: address PR feedbacks * fix: make short_id non null after initial population --- README.md | 8 +- .../api-client/@tanstack/react-query.gen.ts | 21 +- app/client/api-client/sdk.gen.ts | 16 +- app/client/api-client/types.gen.ts | 117 +++ app/client/modules/volumes/tabs/docker.tsx | 6 +- app/drizzle/0012_add_short_ids.sql | 7 + app/drizzle/0013_elite_sprite.sql | 6 + app/drizzle/0014_wild_echo.sql | 40 + app/drizzle/0015_jazzy_sersi.sql | 40 + app/drizzle/meta/0012_snapshot.json | 648 +++++++++++++++++ app/drizzle/meta/0013_snapshot.json | 654 +++++++++++++++++ app/drizzle/meta/0014_snapshot.json | 688 ++++++++++++++++++ app/drizzle/meta/0015_snapshot.json | 688 ++++++++++++++++++ app/drizzle/meta/_journal.json | 28 + app/server/core/constants.ts | 2 + app/server/db/schema.ts | 14 + app/server/index.ts | 7 +- .../modules/driver/driver.controller.ts | 39 +- app/server/modules/lifecycle/checkpoint.ts | 89 +++ app/server/modules/lifecycle/migration.ts | 193 +++++ .../notifications/builders/pushover.ts | 4 +- .../notifications/notifications.service.ts | 6 +- .../repositories/repositories.controller.ts | 11 + .../modules/repositories/repositories.dto.ts | 36 + .../repositories/repositories.service.ts | 54 +- app/server/modules/volumes/helpers.ts | 2 +- app/server/modules/volumes/volume.dto.ts | 2 + app/server/modules/volumes/volume.service.ts | 31 +- app/server/utils/id.ts | 6 + 29 files changed, 3432 insertions(+), 31 deletions(-) create mode 100644 app/drizzle/0012_add_short_ids.sql create mode 100644 app/drizzle/0013_elite_sprite.sql create mode 100644 app/drizzle/0014_wild_echo.sql create mode 100644 app/drizzle/0015_jazzy_sersi.sql create mode 100644 app/drizzle/meta/0012_snapshot.json create mode 100644 app/drizzle/meta/0013_snapshot.json create mode 100644 app/drizzle/meta/0014_snapshot.json create mode 100644 app/drizzle/meta/0015_snapshot.json create mode 100644 app/server/modules/lifecycle/checkpoint.ts create mode 100644 app/server/modules/lifecycle/migration.ts create mode 100644 app/server/utils/id.ts diff --git a/README.md b/README.md index 498e8587..f2f3652e 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ docker compose up -d Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag: ```bash -docker run -v im-nfs:/path/in/container nginx:latest +docker run -v zb-abc12:/path/in/container nginx:latest ``` Or using Docker Compose: @@ -275,13 +275,13 @@ services: myservice: image: nginx:latest volumes: - - im-nfs:/path/in/container + - zb-abc12:/path/in/container volumes: - im-nfs: + zb-abc12: external: true ``` -The volume name format is `im-` where `` is the name you assigned to the volume in Zerobyte. You can verify that the volume is available by running: +The volume name format is `zb-` where `` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running: ```bash docker volume ls diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index b747bfb8..5beda44b 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -442,6 +442,23 @@ export const getRepositoryOptions = (options: Options) => que queryKey: getRepositoryQueryKey(options) }); +/** + * Update a repository's name or settings + */ +export const updateRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateRepository({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listSnapshotsQueryKey = (options: Options) => createQueryKey("listSnapshots", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index df149610..9f5afe46 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -276,6 +276,20 @@ export const getRepository = (options: Opt }); }; +/** + * Update a repository's name or settings + */ +export const updateRepository = (options: Options) => { + return (options.client ?? client).patch({ + url: '/api/v1/repositories/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + /** * List all snapshots in a repository */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index fb477402..a6d86d1c 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -189,6 +189,7 @@ export type ListVolumesResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -279,6 +280,7 @@ export type CreateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -422,6 +424,7 @@ export type GetVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -465,6 +468,7 @@ export type UpdateVolumeData = { ssl?: boolean; username?: string; }; + name?: string; }; path: { name: string; @@ -522,6 +526,7 @@ export type UpdateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -771,6 +776,7 @@ export type ListRepositoriesResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -985,6 +991,7 @@ export type GetRepositoryResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -993,6 +1000,110 @@ export type GetRepositoryResponses = { export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses]; +export type UpdateRepositoryData = { + body?: { + compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; + name?: string; + }; + path: { + name: string; + }; + query?: never; + url: '/api/v1/repositories/{name}'; +}; + +export type UpdateRepositoryErrors = { + /** + * Repository not found + */ + 404: unknown; + /** + * Repository with this name already exists + */ + 409: unknown; +}; + +export type UpdateRepositoryResponses = { + /** + * Repository updated successfully + */ + 200: { + compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; +}; + +export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses]; + export type ListSnapshotsData = { body?: never; path: { @@ -1251,6 +1362,7 @@ export type ListBackupSchedulesResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1304,6 +1416,7 @@ export type ListBackupSchedulesResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -1482,6 +1595,7 @@ export type GetBackupScheduleResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1535,6 +1649,7 @@ export type GetBackupScheduleResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -1694,6 +1809,7 @@ export type GetBackupScheduleForVolumeResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1747,6 +1863,7 @@ export type GetBackupScheduleForVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; diff --git a/app/client/modules/volumes/tabs/docker.tsx b/app/client/modules/volumes/tabs/docker.tsx index b9424798..c03e5687 100644 --- a/app/client/modules/volumes/tabs/docker.tsx +++ b/app/client/modules/volumes/tabs/docker.tsx @@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => { services: { nginx: { image: "nginx:latest", - volumes: [`im-${volume.name}:/path/in/container`], + volumes: [`zb-${volume.shortId}:/path/in/container`], }, }, volumes: { - [`im-${volume.name}`]: { + [`zb-${volume.shortId}`]: { external: true, }, }, }); - const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`; + const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`; const { data: containersData, diff --git a/app/drizzle/0012_add_short_ids.sql b/app/drizzle/0012_add_short_ids.sql new file mode 100644 index 00000000..e4fc6eb6 --- /dev/null +++ b/app/drizzle/0012_add_short_ids.sql @@ -0,0 +1,7 @@ +ALTER TABLE `repositories_table` ADD `short_id` text;--> statement-breakpoint +UPDATE `repositories_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint + +ALTER TABLE `volumes_table` ADD `short_id` text;--> statement-breakpoint +UPDATE `volumes_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`); diff --git a/app/drizzle/0013_elite_sprite.sql b/app/drizzle/0013_elite_sprite.sql new file mode 100644 index 00000000..723e3e24 --- /dev/null +++ b/app/drizzle/0013_elite_sprite.sql @@ -0,0 +1,6 @@ +CREATE TABLE `app_metadata` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/app/drizzle/0014_wild_echo.sql b/app/drizzle/0014_wild_echo.sql new file mode 100644 index 00000000..62f224d5 --- /dev/null +++ b/app/drizzle/0014_wild_echo.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_repositories_table` ( + `id` text PRIMARY KEY NOT NULL, + `short_id` text, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `compression_mode` text DEFAULT 'auto', + `status` text DEFAULT 'unknown', + `last_checked` integer, + `last_error` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint +DROP TABLE `repositories_table`;--> statement-breakpoint +ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint +CREATE TABLE `__new_volumes_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `short_id` text, + `name` text NOT NULL, + `type` text NOT NULL, + `status` text DEFAULT 'unmounted' NOT NULL, + `last_error` text, + `last_health_check` integer DEFAULT (unixepoch()) NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + `config` text NOT NULL, + `auto_remount` integer DEFAULT true NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint +DROP TABLE `volumes_table`;--> statement-breakpoint +ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`); \ No newline at end of file diff --git a/app/drizzle/0015_jazzy_sersi.sql b/app/drizzle/0015_jazzy_sersi.sql new file mode 100644 index 00000000..740f34c9 --- /dev/null +++ b/app/drizzle/0015_jazzy_sersi.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_repositories_table` ( + `id` text PRIMARY KEY NOT NULL, + `short_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `config` text NOT NULL, + `compression_mode` text DEFAULT 'auto', + `status` text DEFAULT 'unknown', + `last_checked` integer, + `last_error` text, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint +DROP TABLE `repositories_table`;--> statement-breakpoint +ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint +CREATE TABLE `__new_volumes_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `short_id` text NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL, + `status` text DEFAULT 'unmounted' NOT NULL, + `last_error` text, + `last_health_check` integer DEFAULT (unixepoch()) NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + `config` text NOT NULL, + `auto_remount` integer DEFAULT true NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint +DROP TABLE `volumes_table`;--> statement-breakpoint +ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`); \ No newline at end of file diff --git a/app/drizzle/meta/0012_snapshot.json b/app/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..02bff3eb --- /dev/null +++ b/app/drizzle/meta/0012_snapshot.json @@ -0,0 +1,648 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bbca8451-3894-4556-9824-c309b5105628", + "prevId": "67552135-fa49-478f-9333-107d3dbd7610", + "tables": { + "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_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())" + } + }, + "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 + }, + "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": "'[]'" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "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())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "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": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/0013_snapshot.json b/app/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..b67ff0ad --- /dev/null +++ b/app/drizzle/meta/0013_snapshot.json @@ -0,0 +1,654 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "794bddf6-1978-46e4-88d5-051d76cfa2f6", + "prevId": "bbca8451-3894-4556-9824-c309b5105628", + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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_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())" + } + }, + "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 + }, + "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": "'[]'" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": ["short_id"], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": ["name"], + "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())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "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": {} + } +} + diff --git a/app/drizzle/meta/0014_snapshot.json b/app/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..89f45cd4 --- /dev/null +++ b/app/drizzle/meta/0014_snapshot.json @@ -0,0 +1,688 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b", + "prevId": "794bddf6-1978-46e4-88d5-051d76cfa2f6", + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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_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())" + } + }, + "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 + }, + "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": "'[]'" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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": false, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "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())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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": false, + "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())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "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": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/0015_snapshot.json b/app/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..745a2bf3 --- /dev/null +++ b/app/drizzle/meta/0015_snapshot.json @@ -0,0 +1,688 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e52fe10a-3f36-4b21-abef-c15990d28363", + "prevId": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b", + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "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_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())" + } + }, + "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 + }, + "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": "'[]'" + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "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())" + } + }, + "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())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "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())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "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": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 6310637b..d69d66b1 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -85,6 +85,34 @@ "when": 1763644043601, "tag": "0011_familiar_stone_men", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1764100562084, + "tag": "0012_add_short_ids", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1764182159797, + "tag": "0013_elite_sprite", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1764182405089, + "tag": "0014_wild_echo", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1764182465287, + "tag": "0015_jazzy_sersi", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 5b970ffc..f0e54235 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -4,3 +4,5 @@ export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock"; + +export const REQUIRED_MIGRATIONS = ["v0.14.0"]; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index f4842d6b..686c4354 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -9,6 +9,7 @@ import type { NotificationType, notificationConfigSchema } from "~/schemas/notif */ export const volumesTable = sqliteTable("volumes_table", { id: int().primaryKey({ autoIncrement: true }), + shortId: text("short_id").notNull().unique(), name: text().notNull().unique(), type: text().$type().notNull(), status: text().$type().notNull().default("unmounted"), @@ -48,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect; */ export const repositoriesTable = sqliteTable("repositories_table", { id: text().primaryKey(), + shortId: text("short_id").notNull().unique(), name: text().notNull().unique(), type: text().$type().notNull(), config: text("config", { mode: "json" }).$type().notNull(), @@ -151,3 +153,15 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif }), })); export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect; + +/** + * App Metadata Table + * Used for storing key-value pairs like migration checkpoints + */ +export const appMetadataTable = sqliteTable("app_metadata", { + key: text().primaryKey(), + value: text().notNull(), + createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), + updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), +}); +export type AppMetadata = typeof appMetadataTable.$inferSelect; diff --git a/app/server/index.ts b/app/server/index.ts index cf741e4c..09e8045b 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -10,6 +10,7 @@ import { authController } from "./modules/auth/auth.controller"; import { requireAuth } from "./modules/auth/auth.middleware"; import { driverController } from "./modules/driver/driver.controller"; import { startup } from "./modules/lifecycle/startup"; +import { migrateToShortIds } from "./modules/lifecycle/migration"; import { repositoriesController } from "./modules/repositories/repositories.controller"; import { systemController } from "./modules/system/system.controller"; import { volumeController } from "./modules/volumes/volume.controller"; @@ -19,7 +20,8 @@ import { notificationsController } from "./modules/notifications/notifications.c import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; -import { SOCKET_PATH } from "./core/constants"; +import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants"; +import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; export const generalDescriptor = (app: Hono) => openAPIRouteHandler(app, { @@ -68,6 +70,9 @@ app.onError((err, c) => { runDbMigrations(); +await migrateToShortIds(); +await validateRequiredMigrations(REQUIRED_MIGRATIONS); + const { docker } = await getCapabilities(); if (docker) { diff --git a/app/server/modules/driver/driver.controller.ts b/app/server/modules/driver/driver.controller.ts index 6418d95f..72a1feb6 100644 --- a/app/server/modules/driver/driver.controller.ts +++ b/app/server/modules/driver/driver.controller.ts @@ -1,6 +1,9 @@ import { Hono } from "hono"; import { volumeService } from "../volumes/volume.service"; import { getVolumePath } from "../volumes/helpers"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/db"; +import { volumesTable } from "../../db/schema"; export const driverController = new Hono() .post("/VolumeDriver.Capabilities", (c) => { @@ -30,10 +33,18 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const volumeName = body.Name.replace(/^zb-/, ""); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ - Mountpoint: getVolumePath(volumeName), + Mountpoint: getVolumePath(volume), }); }) .post("/VolumeDriver.Unmount", (c) => { @@ -48,7 +59,15 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, "")); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ Mountpoint: getVolumePath(volume), @@ -61,11 +80,19 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, "")); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ Volume: { - Name: `zb-${volume.name}`, + Name: `zb-${volume.shortId}`, Mountpoint: getVolumePath(volume), Status: {}, }, @@ -76,7 +103,7 @@ export const driverController = new Hono() const volumes = await volumeService.listVolumes(); const res = volumes.map((volume) => ({ - Name: `zb-${volume.name}`, + Name: `zb-${volume.shortId}`, Mountpoint: getVolumePath(volume), Status: {}, })); diff --git a/app/server/modules/lifecycle/checkpoint.ts b/app/server/modules/lifecycle/checkpoint.ts new file mode 100644 index 00000000..6b15ba37 --- /dev/null +++ b/app/server/modules/lifecycle/checkpoint.ts @@ -0,0 +1,89 @@ +import { eq, sql } from "drizzle-orm"; +import { db } from "../../db/db"; +import { appMetadataTable, usersTable } from "../../db/schema"; +import { logger } from "../../utils/logger"; +import { REQUIRED_MIGRATIONS } from "~/server/core/constants"; + +const MIGRATION_KEY_PREFIX = "migration:"; + +export const recordMigrationCheckpoint = async (version: string): Promise => { + const key = `${MIGRATION_KEY_PREFIX}${version}`; + const now = Math.floor(Date.now() / 1000); + + await db + .insert(appMetadataTable) + .values({ + key, + value: JSON.stringify({ completedAt: new Date().toISOString() }), + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: appMetadataTable.key, + set: { + value: JSON.stringify({ completedAt: new Date().toISOString() }), + updatedAt: now, + }, + }); + + logger.info(`Recorded migration checkpoint for ${version}`); +}; + +export const hasMigrationCheckpoint = async (version: string): Promise => { + const key = `${MIGRATION_KEY_PREFIX}${version}`; + const result = await db.query.appMetadataTable.findFirst({ + where: eq(appMetadataTable.key, key), + }); + return result !== undefined; +}; + +export const validateRequiredMigrations = async (requiredVersions: string[]): Promise => { + const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); + const isFreshInstall = userCount[0]?.count === 0; + + if (isFreshInstall) { + logger.info("Fresh installation detected, skipping migration checkpoint validation."); + + for (const version of requiredVersions) { + const hasCheckpoint = await hasMigrationCheckpoint(version); + if (!hasCheckpoint) { + await recordMigrationCheckpoint(version); + } + } + + return; + } + + for (const version of requiredVersions) { + const hasCheckpoint = await hasMigrationCheckpoint(version); + if (!hasCheckpoint) { + logger.error(` +================================================================================ +MIGRATION ERROR: Required migration ${version} has not been run. + +You are attempting to start a version of Zerobyte that requires migration +checkpoints from previous versions. This typically happens when you skip +versions during an upgrade. + +To fix this: +1. First upgrade to version ${version} and run the application once +2. Validate that everything is still working correctly +3. Then upgrade to the current version + +================================================================================ +`); + process.exit(1); + } + } +}; + +export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => { + const results = await db.query.appMetadataTable.findMany({ + where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`), + }); + + return results.map((r) => ({ + version: r.key.replace(MIGRATION_KEY_PREFIX, ""), + completedAt: JSON.parse(r.value).completedAt, + })); +}; diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts new file mode 100644 index 00000000..5f1cabf6 --- /dev/null +++ b/app/server/modules/lifecycle/migration.ts @@ -0,0 +1,193 @@ +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"; + +const MIGRATION_VERSION = "v0.14.0"; + +interface MigrationResult { + success: boolean; + errors: Array<{ name: string; error: string }>; +} + +export class MigrationError extends Error { + version: string; + failedItems: Array<{ name: string; error: string }>; + + constructor(version: string, failedItems: Array<{ name: string; error: string }>) { + const itemNames = failedItems.map((e) => e.name).join(", "); + super(`Migration ${version} failed for: ${itemNames}`); + this.version = version; + this.failedItems = failedItems; + this.name = "MigrationError"; + } +} + +export const migrateToShortIds = 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})...`); + + const volumeResult = await migrateVolumeFolders(); + const repoResult = await migrateRepositoryFolders(); + + const allErrors = [...volumeResult.errors, ...repoResult.errors]; + + if (allErrors.length > 0) { + for (const err of allErrors) { + logger.error(`Migration failure - ${err.name}: ${err.error}`); + } + throw new MigrationError(MIGRATION_VERSION, allErrors); + } + + await recordMigrationCheckpoint(MIGRATION_VERSION); + + logger.info(`Short ID migration (${MIGRATION_VERSION}) complete.`); +}; + +const migrateVolumeFolders = async (): Promise => { + const errors: Array<{ name: string; error: string }> = []; + const volumes = await db.query.volumesTable.findMany({}); + + for (const volume of volumes) { + if (volume.config.backend === "directory") { + continue; + } + + const oldPath = path.join(VOLUME_MOUNT_BASE, volume.name); + const newPath = path.join(VOLUME_MOUNT_BASE, volume.shortId); + + const oldExists = await pathExists(oldPath); + const newExists = await pathExists(newPath); + + 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.`, + ); + } + } + + return { success: errors.length === 0, errors }; +}; + +const migrateRepositoryFolders = async (): Promise => { + 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; + + 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: Math.floor(Date.now() / 1000), + }) + .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: Math.floor(Date.now() / 1000), + }) + .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: Math.floor(Date.now() / 1000), + }) + .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 => { + try { + await fs.access(p); + return true; + } catch { + return false; + } +}; diff --git a/app/server/modules/notifications/builders/pushover.ts b/app/server/modules/notifications/builders/pushover.ts index 5f735a09..75c04929 100644 --- a/app/server/modules/notifications/builders/pushover.ts +++ b/app/server/modules/notifications/builders/pushover.ts @@ -1,8 +1,6 @@ import type { NotificationConfig } from "~/schemas/notifications"; -export function buildPushoverShoutrrrUrl( - config: Extract, -): string { +export function buildPushoverShoutrrrUrl(config: Extract): string { const params = new URLSearchParams(); if (config.devices) { diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index a3a83c58..13932fac 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; @@ -164,10 +164,10 @@ const updateDestination = async ( const slug = slugify(updates.name, { lower: true, strict: true }); const conflict = await db.query.notificationDestinationsTable.findFirst({ - where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)), + where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)), }); - if (conflict && conflict.id !== id) { + if (conflict) { throw new ConflictError("Notification destination with this name already exists"); } updateData.name = slug; diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index e4ed4fc3..c629deca 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -16,6 +16,8 @@ import { listSnapshotsFilters, restoreSnapshotBody, restoreSnapshotDto, + updateRepositoryBody, + updateRepositoryDto, type DeleteRepositoryDto, type DeleteSnapshotDto, type DoctorRepositoryDto, @@ -25,6 +27,7 @@ import { type ListSnapshotFilesDto, type ListSnapshotsDto, type RestoreSnapshotDto, + type UpdateRepositoryDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; @@ -152,4 +155,12 @@ export const repositoriesController = new Hono() await repositoriesService.deleteSnapshot(name, snapshotId); return c.json({ message: "Snapshot deleted" }, 200); + }) + .patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => { + const { name } = c.req.param(); + const body = c.req.valid("json"); + + const res = await repositoriesService.updateRepository(name, body); + + return c.json(res.repository, 200); }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 8d85808f..e54af333 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -4,6 +4,7 @@ import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryCo export const repositorySchema = type({ id: "string", + shortId: "string", name: "string", type: type.valueOf(REPOSITORY_BACKENDS), config: repositoryConfigSchema, @@ -123,6 +124,41 @@ export const deleteRepositoryDto = describeRoute({ }, }); +/** + * Update a repository + */ +export const updateRepositoryBody = type({ + name: "string?", + compressionMode: type.valueOf(COMPRESSION_MODES).optional(), +}); + +export type UpdateRepositoryBody = typeof updateRepositoryBody.infer; + +export const updateRepositoryResponse = repositorySchema; +export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer; + +export const updateRepositoryDto = describeRoute({ + description: "Update a repository's name or settings", + tags: ["Repositories"], + operationId: "updateRepository", + responses: { + 200: { + description: "Repository updated successfully", + content: { + "application/json": { + schema: resolver(updateRepositoryResponse), + }, + }, + }, + 404: { + description: "Repository not found", + }, + 409: { + description: "Repository with this name already exists", + }, + }, +}); + /** * List snapshots in a repository */ diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 4c711d57..3ce1be25 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -1,10 +1,11 @@ import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; +import { generateShortId } from "../../utils/id"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; @@ -61,13 +62,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress } const id = crypto.randomUUID(); + const shortId = generateShortId(); - const encryptedConfig = await encryptConfig(config); + let processedConfig = config; + if (config.backend === "local") { + processedConfig = { ...config, name: shortId }; + } + + const encryptedConfig = await encryptConfig(processedConfig); const [created] = await db .insert(repositoriesTable) .values({ id, + shortId, name: slug, type: config.backend, config: encryptedConfig, @@ -350,11 +358,53 @@ const deleteSnapshot = async (name: string, snapshotId: string) => { await restic.deleteSnapshot(repository.config, snapshotId); }; +const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => { + const existing = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!existing) { + throw new NotFoundError("Repository not found"); + } + + let newName = existing.name; + if (updates.name !== undefined && updates.name !== existing.name) { + const newSlug = slugify(updates.name, { lower: true, strict: true }); + + const conflict = await db.query.repositoriesTable.findFirst({ + where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)), + }); + + if (conflict) { + throw new ConflictError("A repository with this name already exists"); + } + + newName = newSlug; + } + + const [updated] = await db + .update(repositoriesTable) + .set({ + name: newName, + compressionMode: updates.compressionMode ?? existing.compressionMode, + updatedAt: Math.floor(Date.now() / 1000), + }) + .where(eq(repositoriesTable.id, existing.id)) + .returning(); + + if (!updated) { + throw new InternalServerError("Failed to update repository"); + } + + return { repository: updated }; +}; + export const repositoriesService = { listRepositories, createRepository, getRepository, deleteRepository, + updateRepository, listSnapshots, listSnapshotFiles, restoreSnapshot, diff --git a/app/server/modules/volumes/helpers.ts b/app/server/modules/volumes/helpers.ts index bb272651..a9f8207a 100644 --- a/app/server/modules/volumes/helpers.ts +++ b/app/server/modules/volumes/helpers.ts @@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => { return volume.config.path; } - return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`; + return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`; }; diff --git a/app/server/modules/volumes/volume.dto.ts b/app/server/modules/volumes/volume.dto.ts index 7f2ea400..03d61987 100644 --- a/app/server/modules/volumes/volume.dto.ts +++ b/app/server/modules/volumes/volume.dto.ts @@ -4,6 +4,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/vol export const volumeSchema = type({ id: "number", + shortId: "string", name: "string", type: type.valueOf(BACKEND_TYPES), status: type.valueOf(BACKEND_STATUS), @@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({ * Update a volume */ export const updateVolumeBody = type({ + name: "string?", autoRemount: "boolean?", config: volumeConfigSchema.optional(), }); diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 269b40fc..7169d6fc 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -2,13 +2,14 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import Docker from "dockerode"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { getCapabilities } from "../../core/capabilities"; import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; +import { generateShortId } from "../../utils/id"; import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { withTimeout } from "../../utils/timeout"; import { createVolumeBackend } from "../backends/backend"; @@ -35,9 +36,12 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { throw new ConflictError("Volume already exists"); } + const shortId = generateShortId(); + const [created] = await db .insert(volumesTable) .values({ + shortId, name: slug, config: backendConfig, type: backendConfig.backend, @@ -147,6 +151,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { throw new NotFoundError("Volume not found"); } + let newName = existing.name; + if (volumeData.name !== undefined && volumeData.name !== existing.name) { + const newSlug = slugify(volumeData.name, { lower: true, strict: true }); + + const conflict = await db.query.volumesTable.findFirst({ + where: and(eq(volumesTable.name, newSlug), ne(volumesTable.id, existing.id)), + }); + + if (conflict) { + throw new ConflictError("A volume with this name already exists"); + } + + newName = newSlug; + } + const configChanged = JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined; @@ -159,12 +178,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { const [updated] = await db .update(volumesTable) .set({ + name: newName, config: volumeData.config, type: volumeData.config?.backend, autoRemount: volumeData.autoRemount, updatedAt: Date.now(), }) - .where(eq(volumesTable.name, name)) + .where(eq(volumesTable.id, existing.id)) .returning(); if (!updated) { @@ -177,9 +197,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { await db .update(volumesTable) .set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) - .where(eq(volumesTable.name, name)); + .where(eq(volumesTable.id, existing.id)); - serverEvents.emit("volume:updated", { volumeName: name }); + serverEvents.emit("volume:updated", { volumeName: updated.name }); } return { volume: updated }; @@ -190,6 +210,7 @@ const testConnection = async (backendConfig: BackendConfig) => { const mockVolume = { id: 0, + shortId: "test", name: "test-connection", path: tempDir, config: backendConfig, @@ -264,7 +285,7 @@ const getContainersUsingVolume = async (name: string) => { const container = docker.getContainer(info.Id); const inspect = await container.inspect(); const mounts = inspect.Mounts || []; - const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`); + const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `zb-${volume.shortId}`); if (usesVolume) { usingContainers.push({ id: inspect.Id, diff --git a/app/server/utils/id.ts b/app/server/utils/id.ts new file mode 100644 index 00000000..4ecfb662 --- /dev/null +++ b/app/server/utils/id.ts @@ -0,0 +1,6 @@ +import crypto from "node:crypto"; + +export const generateShortId = (length = 5): string => { + const bytesNeeded = Math.ceil((length * 3) / 4); + return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length); +};