@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
570 lines (519 loc) • 17.6 kB
text/typescript
/**
* Backup Service for BackendSDK
*
* Provides encrypted backup and restore functionality for KRAPI projects.
* Supports:
* - Individual project backups (encrypted)
* - System-wide backups (encrypted)
* - Backup versioning
* - Secure backup storage
*
* Uses a BackupBackend abstraction to support different backup storage implementations
* (file-based, Restic, etc.)
*/
import * as crypto from "crypto";
import * as path from "path";
import { DatabaseConnection, Logger } from "./core";
import { KrapiError } from "./core/krapi-error";
import { BackupBackend, BackupSnapshot } from "./services/backup/backup-backend.interface";
import { FileBackupBackend } from "./services/backup/file-backup-backend";
import { normalizeError } from "./utils/error-handler";
export interface BackupMetadata {
id: string;
project_id?: string;
type: "project" | "system";
created_at: string;
size: number;
encrypted: boolean;
version: string;
description?: string;
/** Restic snapshot ID (for Restic backend) */
snapshot_id?: string;
/** Unique data size after deduplication */
unique_size?: number;
/** Number of files in backup */
file_count?: number;
/** Backup tags */
tags?: string[];
}
export interface BackupOptions {
projectId?: string;
description?: string;
password?: string;
includeFiles?: boolean;
compressionLevel?: number;
}
export interface RestoreOptions {
password?: string;
overwrite?: boolean;
targetPath?: string;
targetProjectId?: string;
}
/**
* Backup Service for KRAPI
*
* Provides encrypted backup and restore functionality.
* Supports project backups and system-wide backups with encryption.
*
* @class BackupService
* @example
* const backupService = new BackupService(dbConnection, logger);
* const backup = await backupService.backupProject({ projectId: 'project-id', password: 'encryption-password' });
*/
export class BackupService {
private backend: BackupBackend;
private dataPath: string;
/**
* Create a new BackupService instance
*
* @param dbConnection - Database connection
* @param logger - Logger instance
* @param backupBackend - Optional backup backend (defaults to FileBackupBackend)
* @param backupsDir - Backup directory path (for FileBackupBackend, default: data/backups)
* @param dataPath - Data directory path (default: data)
*/
constructor(
private dbConnection: DatabaseConnection,
private logger: Logger = console,
backupBackend?: BackupBackend,
backupsDir?: string,
dataPath?: string
) {
this.dataPath = dataPath || path.join(process.cwd(), "data");
// Use provided backend or create default file-based backend
if (backupBackend) {
this.backend = backupBackend;
} else {
this.backend = new FileBackupBackend(
dbConnection,
logger,
backupsDir,
this.dataPath
);
}
}
/**
* Convert BackupSnapshot to BackupMetadata
*/
private snapshotToMetadata(snapshot: BackupSnapshot): BackupMetadata {
const metadata: BackupMetadata = {
id: snapshot.id,
type: snapshot.type,
created_at: snapshot.created_at,
size: snapshot.size,
encrypted: snapshot.encrypted,
version: snapshot.version,
};
if (snapshot.project_id !== undefined) {
metadata.project_id = snapshot.project_id;
}
if (snapshot.description !== undefined) {
metadata.description = snapshot.description;
}
if (snapshot.snapshot_id !== undefined) {
metadata.snapshot_id = snapshot.snapshot_id;
}
if (snapshot.unique_size !== undefined) {
metadata.unique_size = snapshot.unique_size;
}
if (snapshot.file_count !== undefined) {
metadata.file_count = snapshot.file_count;
}
if (snapshot.tags !== undefined) {
metadata.tags = snapshot.tags;
}
return metadata;
}
/**
* Save backup metadata to database
*/
private async saveBackupMetadata(
snapshot: BackupSnapshot,
filePath?: string
): Promise<void> {
try {
// For file-based backend, filePath is the actual file path
// For Restic backend, filePath would be a virtual path like "restic://snapshot-id"
const virtualPath = filePath || `restic://${snapshot.snapshot_id}`;
await this.dbConnection.query(
`INSERT INTO backups (id, project_id, type, created_at, size, encrypted, version, description, file_path, snapshot_id, unique_size, file_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
snapshot.id,
snapshot.project_id || null,
snapshot.type,
snapshot.created_at,
snapshot.size,
snapshot.encrypted ? 1 : 0, // SQLite stores booleans as integers
snapshot.version,
snapshot.description || null,
virtualPath,
snapshot.snapshot_id || null,
snapshot.unique_size || null,
snapshot.file_count || null,
]
);
} catch (error) {
// If backups table doesn't exist or columns don't exist, just log warning
this.logger.warn("Could not save backup metadata to database:", error);
}
}
/**
* Get backup metadata from database
*/
private async getBackupMetadata(backupId: string): Promise<BackupMetadata | null> {
try {
const result = await this.dbConnection.query(
"SELECT * FROM backups WHERE id = $1",
[backupId]
);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0] as Record<string, unknown>;
const metadata: BackupMetadata = {
id: row.id as string,
type: row.type as "project" | "system",
created_at: row.created_at as string,
size: row.size as number,
encrypted: (row.encrypted as number) === 1,
version: (row.version as string) || "2.0.0",
};
if (row.project_id !== undefined && row.project_id !== null) {
metadata.project_id = row.project_id as string;
}
if (row.description !== undefined && row.description !== null) {
metadata.description = row.description as string;
}
if (row.snapshot_id !== undefined && row.snapshot_id !== null) {
metadata.snapshot_id = row.snapshot_id as string;
}
if (row.unique_size !== undefined && row.unique_size !== null) {
metadata.unique_size = row.unique_size as number;
}
if (row.file_count !== undefined && row.file_count !== null) {
metadata.file_count = row.file_count as number;
}
if (row.tags !== undefined && row.tags !== null) {
metadata.tags = JSON.parse(row.tags as string);
}
return metadata;
} catch (error) {
this.logger.error("Failed to get backup metadata:", error);
return null;
}
}
/**
* Delete backup metadata from database
*/
private async deleteBackupMetadata(backupId: string): Promise<void> {
try {
await this.dbConnection.query("DELETE FROM backups WHERE id = $1", [backupId]);
} catch (error) {
this.logger.warn("Could not delete backup metadata from database:", error);
}
}
/**
* Create encrypted backup of a project
*
* Creates an encrypted backup of a project's database and optionally files.
*
* @param options - Backup options
* @param options.projectId - Project ID (required)
* @param options.description - Backup description
* @param options.password - Encryption password (generated if not provided)
* @param options.includeFiles - Whether to include files in backup
* @returns Backup metadata with password
* @throws {Error} If project not found or backup fails
*
* @example
* const backup = await backupService.backupProject({
* projectId: 'project-id',
* description: 'Monthly backup',
* password: 'secure-password'
* });
*/
async backupProject(
options: BackupOptions
): Promise<BackupMetadata & { password: string }> {
if (!options.projectId) {
throw KrapiError.validationError(
"Project ID is required for project backup",
"projectId"
);
}
try {
const password = options.password || this.generatePassword();
// Use backend to create backup
const backupOptions: {
password: string;
description?: string;
includeFiles?: boolean;
tags: string[];
} = {
password,
includeFiles: options.includeFiles ?? true,
tags: ["project", options.projectId],
};
if (options.description !== undefined) {
backupOptions.description = options.description;
}
const snapshot = await this.backend.createProjectBackup(
options.projectId,
backupOptions
);
// Save metadata to database
await this.saveBackupMetadata(snapshot);
this.logger.info(`Created encrypted backup: ${snapshot.id}`);
return {
...this.snapshotToMetadata(snapshot),
password,
} as BackupMetadata & { password: string };
} catch (error) {
this.logger.error("Failed to create project backup:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "backupProject",
projectId: options.projectId,
});
}
}
/**
* Create system-wide backup (all projects + main database)
*
* @param options - Backup options
* @param options.description - Backup description
* @param options.password - Encryption password (generated if not provided)
* @returns Backup metadata with password
*/
async backupSystem(
options: BackupOptions = {}
): Promise<BackupMetadata & { password: string }> {
try {
const password = options.password || this.generatePassword();
// Use backend to create backup
const backupOptions: {
password: string;
description?: string;
tags: string[];
} = {
password,
tags: ["system", "full"],
};
if (options.description !== undefined) {
backupOptions.description = options.description;
}
const snapshot = await this.backend.createSystemBackup(backupOptions);
// Save metadata to database
await this.saveBackupMetadata(snapshot);
this.logger.info(`Created encrypted system backup: ${snapshot.id}`);
return {
...this.snapshotToMetadata(snapshot),
password,
} as BackupMetadata & { password: string };
} catch (error) {
this.logger.error("Failed to create system backup:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "backupSystem",
});
}
}
/**
* Restore project from encrypted backup
*
* @param backupId - Backup ID to restore
* @param options - Restore options
* @param options.password - Password required to restore encrypted backup
* @param options.overwrite - Whether to overwrite existing project
* @param options.targetPath - Target path for restoration (optional)
* @param options.targetProjectId - Target project ID (optional)
*/
async restoreProject(
backupId: string,
options: RestoreOptions = {}
): Promise<void> {
try {
if (!options.password) {
throw KrapiError.validationError(
"Password is required to restore encrypted backup",
"password"
);
}
// Get backup metadata
const metadata = await this.getBackupMetadata(backupId);
if (!metadata) {
throw KrapiError.notFound(`Backup '${backupId}' not found`, {
backupId,
operation: "restoreProject",
});
}
// Determine target path
let targetPath: string;
if (options.targetPath) {
targetPath = options.targetPath;
} else if (metadata.type === "project") {
const projectId = options.targetProjectId || metadata.project_id;
if (!projectId) {
throw KrapiError.validationError(
"Project ID is required for project backup restoration",
"projectId"
);
}
targetPath = path.join(this.dataPath, "projects");
} else {
targetPath = this.dataPath;
}
// Use snapshot_id if available, otherwise use id
const snapshotId = metadata.snapshot_id || metadata.id;
// Restore using backend
await this.backend.restoreBackup(snapshotId, options.password, targetPath);
// For project backups, also restore project metadata in database
if (metadata.type === "project" && metadata.project_id) {
// Get project data from backup to restore metadata
// Note: This is a simplified version - full restore would need to read backup file
// For now, we rely on the backend to handle the actual restoration
const projectId = options.targetProjectId || metadata.project_id;
// Check if project exists and overwrite flag
const existingProject = await this.dbConnection.query(
"SELECT id FROM projects WHERE id = $1",
[projectId]
);
if (existingProject.rows.length > 0 && !options.overwrite) {
throw KrapiError.conflict(
`Project ${projectId} already exists. Use overwrite option to restore anyway.`,
{
resource: "projects",
operation: "restoreProject",
projectId,
backupId,
}
);
}
}
this.logger.info(`Restored backup: ${backupId}`);
} catch (error) {
this.logger.error("Failed to restore backup:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "restoreProject",
backupId,
});
}
}
/**
* List all backups
*
* @param projectId - Optional project ID filter
* @param type - Optional backup type filter
* @returns Array of backup metadata
*/
async listBackups(
projectId?: string,
type?: "project" | "system"
): Promise<BackupMetadata[]> {
try {
// Use backend to list backups (requires password, but we'll use a dummy for listing)
// Note: For file-based backend, this queries the database
// For Restic backend, this would query the repository
const password = "dummy"; // Backend implementations should handle listing without password
const listOptions: {
projectId?: string;
type?: "project" | "system";
} = {};
if (projectId !== undefined) {
listOptions.projectId = projectId;
}
if (type !== undefined) {
listOptions.type = type;
}
const snapshots = await this.backend.listBackups(password, listOptions);
return snapshots.map((snapshot) => this.snapshotToMetadata(snapshot));
} catch (error) {
this.logger.error("Failed to list backups:", error);
// If backups table doesn't exist, return empty array
if (
error instanceof Error &&
(error.message.includes("no such table") ||
error.message.includes("does not exist"))
) {
return [];
}
throw error;
}
}
/**
* Get backup details
*
* @param backupId - Backup ID
* @param password - Backup password (optional, for stats)
* @returns Backup metadata
*/
async getBackup(
backupId: string,
password?: string
): Promise<BackupMetadata> {
try {
// Get metadata from database
const metadata = await this.getBackupMetadata(backupId);
if (!metadata) {
throw KrapiError.notFound(`Backup '${backupId}' not found`, {
backupId,
operation: "getBackup",
});
}
// If password provided, get stats from backend
if (password && metadata.snapshot_id) {
try {
const stats = await this.backend.getBackupStats(
metadata.snapshot_id,
password
);
return {
...metadata,
unique_size: stats.unique_size,
file_count: stats.file_count,
};
} catch (error) {
this.logger.warn("Could not get backup stats:", error);
}
}
return metadata;
} catch (error) {
this.logger.error("Failed to get backup:", error);
throw error;
}
}
/**
* Delete backup
*
* @param backupId - Backup ID to delete
* @param password - Backup password (required for some backends)
*/
async deleteBackup(backupId: string, password?: string): Promise<void> {
try {
// Get metadata to find snapshot_id
const metadata = await this.getBackupMetadata(backupId);
if (!metadata) {
throw KrapiError.notFound(`Backup '${backupId}' not found`, {
backupId,
operation: "deleteBackup",
});
}
// Use snapshot_id if available, otherwise use id
const snapshotId = metadata.snapshot_id || metadata.id;
// Delete from backend (password may be required)
const deletePassword = password || "dummy"; // Some backends may not require password
await this.backend.deleteBackup(snapshotId, deletePassword);
// Delete metadata from database
await this.deleteBackupMetadata(backupId);
this.logger.info(`Deleted backup: ${backupId}`);
} catch (error) {
this.logger.error("Failed to delete backup:", error);
throw error;
}
}
/**
* Generate random password
*/
private generatePassword(): string {
return crypto.randomBytes(32).toString("hex");
}
}