@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
text/typescript
/**
* 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);
}
}
}