UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

278 lines (263 loc) 11.8 kB
/** * Backup Adapter * * Unifies BackupHttpClient and BackupService behind a common interface. */ import { BackupService, BackupMetadata } from "../../backup-service"; import { BackupHttpClient } from "../../http-clients/backup-http-client"; import { createAdapterInitError } from "./error-handler"; type Mode = "client" | "server"; export class BackupAdapter { private mode: Mode; private httpClient: BackupHttpClient | undefined; private service: BackupService | undefined; constructor(mode: Mode, httpClient?: BackupHttpClient, service?: BackupService) { this.mode = mode; this.httpClient = httpClient; this.service = service; } async createProject(projectId: string, options?: { description?: string; password?: string; includeFiles?: boolean; }): Promise<BackupMetadata> { if (this.mode === "client") { if (!this.httpClient) { throw createAdapterInitError("HTTP client", this.mode); } const response = await this.httpClient.createProjectBackup(projectId, options); // Response interceptor returns response.data from axios, so response might be: // 1. { success: true, data: BackupMetadata } (if backend wrapped in data) // 2. BackupMetadata directly (if backend returned directly) // Handle multiple response formats to ensure snapshot_id is always included if (response && typeof response === "object") { // Priority 1: If response has 'data' field, extract it (backend wrapped response) if ("data" in response && response.data) { const data = response.data as unknown as Record<string, unknown>; // Ensure snapshot_id is included return { id: (data.id || data.backup_id) as string, type: (data.type as "project" | "system") || "project", created_at: data.created_at as string, size: (data.size as number) || 0, encrypted: (data.encrypted as boolean) || false, version: (data.version as string) || "2.0.0", description: data.description as string | undefined, snapshot_id: (data.snapshot_id as string | undefined) || (data.id as string | undefined), project_id: data.project_id as string | undefined, unique_size: data.unique_size as number | undefined, file_count: data.file_count as number | undefined, tags: data.tags as string[] | undefined, } as BackupMetadata; } // Priority 2: If response has 'success' at top level with backup fields, extract the backup data if ("success" in response && ("id" in response || "backup_id" in response)) { const backupData = response as Record<string, unknown>; // Map backup_id to id if needed, and ensure snapshot_id is included return { id: (backupData.id || backupData.backup_id) as string, type: (backupData.type as "project" | "system") || "project", created_at: backupData.created_at as string, size: (backupData.size as number) || 0, encrypted: (backupData.encrypted as boolean) || false, version: (backupData.version as string) || "2.0.0", description: backupData.description as string | undefined, snapshot_id: (backupData.snapshot_id as string | undefined) || (backupData.id as string | undefined), project_id: backupData.project_id as string | undefined, unique_size: backupData.unique_size as number | undefined, file_count: backupData.file_count as number | undefined, tags: backupData.tags as string[] | undefined, } as BackupMetadata; } // Priority 3: If response is BackupMetadata directly (has id, type, snapshot_id, etc.) if ("id" in response && "type" in response) { const backupData = response as Record<string, unknown>; // Ensure snapshot_id is present (use id as fallback if missing) return { id: backupData.id as string, type: backupData.type as "project" | "system", created_at: backupData.created_at as string, size: (backupData.size as number) || 0, encrypted: (backupData.encrypted as boolean) || false, version: (backupData.version as string) || "2.0.0", description: backupData.description as string | undefined, snapshot_id: (backupData.snapshot_id as string | undefined) || (backupData.id as string | undefined), project_id: backupData.project_id as string | undefined, unique_size: backupData.unique_size as number | undefined, file_count: backupData.file_count as number | undefined, tags: backupData.tags as string[] | undefined, } as BackupMetadata; } } return {} as BackupMetadata; } else { if (!this.service) { throw createAdapterInitError("Backup service", this.mode); } const result = await this.service.backupProject({ projectId, ...options }); // Remove password from result for consistency const { password: _password, ...metadata } = result; return metadata; } } async restoreProject(_projectId: string, backupId: string, password: string, options?: { overwrite?: boolean; }): Promise<{ success: boolean }> { if (this.mode === "client") { if (!this.httpClient) { throw createAdapterInitError("HTTP client", this.mode); } // Response interceptor returns response.data from axios, so response might be: // 1. { success: true, data: { success: true } } (if backend wrapped in data) // 2. { success: true } (if backend returned directly) // 3. { success: boolean, message?: string } (direct result) const response = await this.httpClient.restore(backupId, { password, ...options }); // Handle multiple response formats if (response && typeof response === "object") { // If response has 'data' field, extract it (backend wrapped response) if ("data" in response && response.data) { const data = response.data as unknown as { success?: boolean; message?: string }; return { success: data.success ?? true }; } // If response has 'success' field directly, use it if ("success" in response) { return { success: response.success as boolean }; } } // Fallback: assume success if we got a response return { success: true }; } else { if (!this.service) { throw createAdapterInitError("Backup service", this.mode); } await this.service.restoreProject(backupId, { password, ...options }); return { success: true }; } } async list(projectId?: string, type?: "project" | "system"): Promise<BackupMetadata[]> { if (this.mode === "client") { if (!this.httpClient) { throw createAdapterInitError("HTTP client", this.mode); } const options: { projectId?: string; type?: "project" | "system"; } = {}; if (projectId) options.projectId = projectId; if (type) options.type = type; try { const response = await this.httpClient.list(options); const data = response.data; if (data && "data" in data) { return (data.data as BackupMetadata[]) || []; } return (data as unknown as BackupMetadata[]) || []; } catch (error) { // Handle Restic repository errors gracefully // If repository is not initialized or password is wrong, return empty array if (error instanceof Error) { const errorMessage = error.message.toLowerCase(); if ( errorMessage.includes("restic") && (errorMessage.includes("wrong password") || errorMessage.includes("no key found") || errorMessage.includes("repository not found") || errorMessage.includes("not initialized")) ) { // Repository not initialized or password issue - return empty array // This is expected when no backups have been created yet return []; } } // Re-throw other errors throw error; } } else { if (!this.service) { throw createAdapterInitError("Backup service", this.mode); } try { const backups = await this.service.listBackups(projectId, type); return backups || []; } catch (error) { // Handle Restic repository errors gracefully in server mode if (error instanceof Error) { const errorMessage = error.message.toLowerCase(); if ( errorMessage.includes("restic") && (errorMessage.includes("wrong password") || errorMessage.includes("no key found") || errorMessage.includes("repository not found") || errorMessage.includes("not initialized")) ) { // Repository not initialized - return empty array return []; } } // Re-throw other errors throw error; } } } async delete(backupId: string): Promise<{ success: boolean }> { if (this.mode === "client") { if (!this.httpClient) { throw createAdapterInitError("HTTP client", this.mode); } // Response interceptor returns response.data from axios, so response might be: // 1. { success: true, data: { success: true } } (if backend wrapped in data) // 2. { success: true } (if backend returned directly) // 3. { success: boolean } (direct result) const response = await this.httpClient.deleteBackup(backupId); // Handle multiple response formats if (response && typeof response === "object") { // If response has 'data' field, extract it (backend wrapped response) if ("data" in response && response.data) { const data = response.data as unknown as { success?: boolean }; return { success: data.success ?? true }; } // If response has 'success' field directly, use it if ("success" in response) { return { success: response.success as boolean }; } } // Fallback: assume success if we got a response return { success: true }; } else { if (!this.service) { throw createAdapterInitError("Backup service", this.mode); } await this.service.deleteBackup(backupId); return { success: true }; } } async createSystem(options?: { description?: string; password?: string; }): Promise<BackupMetadata> { if (this.mode === "client") { if (!this.httpClient) { throw createAdapterInitError("HTTP client", this.mode); } const response = await this.httpClient.createSystemBackup(options); // Response interceptor returns response.data, so response is already unwrapped // Handle both formats: BackupMetadata directly or { success: true, data: BackupMetadata } if (response && typeof response === "object") { // If response has 'data' field, extract it if ("data" in response && response.data) { return (response.data as unknown as BackupMetadata) || ({} as BackupMetadata); } // If response is BackupMetadata directly (has id, type, etc.) if ("id" in response && "type" in response) { return response as unknown as BackupMetadata; } } return {} as BackupMetadata; } else { if (!this.service) { throw createAdapterInitError("Backup service", this.mode); } return await this.service.backupSystem(options); } } }