tero
Version:
tero is a JSON document Manager, with ACID complaint and backup recovery mechanism.
318 lines (317 loc) • 11.4 kB
JavaScript
import { S3Client, GetObjectCommand, ListObjectsV2Command, HeadObjectCommand } from "@aws-sdk/client-s3";
import { createWriteStream, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { pipeline } from "stream/promises";
export class DataRecovery {
s3Client;
config;
constructor(config) {
this.config = config;
this.initializeCloudStorage();
}
initializeCloudStorage() {
try {
const clientConfig = {
region: this.config.cloudStorage.region,
credentials: {
accessKeyId: this.config.cloudStorage.accessKeyId,
secretAccessKey: this.config.cloudStorage.secretAccessKey,
},
};
// Configure for Cloudflare R2 or custom endpoints
if (this.config.cloudStorage.endpoint) {
clientConfig.endpoint = this.config.cloudStorage.endpoint;
clientConfig.forcePathStyle = true; // Required for R2
}
this.s3Client = new S3Client(clientConfig);
}
catch (error) {
throw new Error(`Failed to initialize cloud storage for recovery: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
getCloudKey(filename) {
const prefix = this.config.cloudStorage.pathPrefix || 'tero-backups';
const dbName = this.config.localPath.split('/').pop() || 'database';
return `${prefix}/${dbName}/${filename}`;
}
async checkFileInCloud(key) {
try {
const cloudKey = this.getCloudKey(`${key}.json`);
const headCommand = new HeadObjectCommand({
Bucket: this.config.cloudStorage.bucket,
Key: cloudKey
});
const response = await this.s3Client.send(headCommand);
return {
key,
exists: true,
size: response.ContentLength,
lastModified: response.LastModified
};
}
catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
return {
key,
exists: false
};
}
throw new Error(`Failed to check file in cloud: ${error.message}`);
}
}
async recoverSingleFile(key) {
try {
const cloudKey = this.getCloudKey(`${key}.json`);
const localFilePath = join(this.config.localPath, `${key}.json`);
// Ensure local directory exists
const localDir = dirname(localFilePath);
if (!existsSync(localDir)) {
mkdirSync(localDir, { recursive: true });
}
const getCommand = new GetObjectCommand({
Bucket: this.config.cloudStorage.bucket,
Key: cloudKey
});
const response = await this.s3Client.send(getCommand);
if (!response.Body) {
throw new Error('No data received from cloud storage');
}
// Stream the file to local storage
const writeStream = createWriteStream(localFilePath);
await pipeline(response.Body, writeStream);
return true;
}
catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
return false;
}
return false;
}
}
async recoverFromArchive(archiveName) {
const startTime = Date.now();
const recovered = [];
const failed = [];
try {
// List available archives if no specific archive is provided
if (!archiveName) {
const archives = await this.listAvailableArchives();
if (archives.length === 0) {
throw new Error('No backup archives found in cloud storage');
}
// Use the most recent archive
archiveName = archives[0];
}
const cloudKey = this.getCloudKey(archiveName);
const localArchivePath = join(this.config.localPath, archiveName);
// Download the archive
const downloadSuccess = await this.downloadFile(cloudKey, localArchivePath);
if (!downloadSuccess) {
throw new Error(`Failed to download archive: ${archiveName}`);
}
// Extract the archive
const extractSuccess = await this.extractArchive(localArchivePath);
if (extractSuccess) {
recovered.push(archiveName);
}
else {
failed.push(archiveName);
}
const duration = Date.now() - startTime;
return {
success: recovered.length > 0,
recovered,
failed,
totalFiles: 1,
duration
};
}
catch (error) {
const duration = Date.now() - startTime;
return {
success: false,
recovered,
failed: [archiveName || 'unknown'],
totalFiles: 1,
duration
};
}
}
async recoverIndividualFiles(keys) {
const startTime = Date.now();
const recovered = [];
const failed = [];
try {
// If no keys provided, list all available files
if (!keys || keys.length === 0) {
keys = await this.listAvailableFiles();
if (keys.length === 0) {
throw new Error('No backup files found in cloud storage');
}
}
// Recover each file
for (const key of keys) {
const success = await this.recoverSingleFile(key);
if (success) {
recovered.push(key);
}
else {
failed.push(key);
}
}
const duration = Date.now() - startTime;
return {
success: recovered.length > 0,
recovered,
failed,
totalFiles: keys.length,
duration
};
}
catch (error) {
const duration = Date.now() - startTime;
return {
success: false,
recovered,
failed: keys || [],
totalFiles: keys?.length || 0,
duration
};
}
}
async listAvailableArchives() {
try {
const prefix = this.getCloudKey('');
const listCommand = new ListObjectsV2Command({
Bucket: this.config.cloudStorage.bucket,
Prefix: prefix,
MaxKeys: 100
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents) {
return [];
}
// Filter for archive files and sort by date (newest first)
const archives = response.Contents
.filter((obj) => obj.Key && obj.Key.endsWith('.tar.gz'))
.sort((a, b) => {
const dateA = a.LastModified?.getTime() || 0;
const dateB = b.LastModified?.getTime() || 0;
return dateB - dateA; // Newest first
})
.map((obj) => obj.Key.split('/').pop())
.filter(Boolean);
return archives;
}
catch (error) {
return [];
}
}
async listAvailableFiles() {
try {
const prefix = this.getCloudKey('');
const listCommand = new ListObjectsV2Command({
Bucket: this.config.cloudStorage.bucket,
Prefix: prefix,
MaxKeys: 1000
});
const response = await this.s3Client.send(listCommand);
if (!response.Contents) {
return [];
}
// Filter for JSON files and extract keys
const files = response.Contents
.filter((obj) => obj.Key && obj.Key.endsWith('.json'))
.map((obj) => {
const filename = obj.Key.split('/').pop();
return filename.replace('.json', '');
})
.filter(Boolean);
return files;
}
catch (error) {
return [];
}
}
async downloadFile(cloudKey, localPath) {
try {
const getCommand = new GetObjectCommand({
Bucket: this.config.cloudStorage.bucket,
Key: cloudKey
});
const response = await this.s3Client.send(getCommand);
if (!response.Body) {
return false;
}
// Ensure local directory exists
const localDir = dirname(localPath);
if (!existsSync(localDir)) {
mkdirSync(localDir, { recursive: true });
}
const writeStream = createWriteStream(localPath);
await pipeline(response.Body, writeStream);
return true;
}
catch (error) {
return false;
}
}
async extractArchive(archivePath) {
try {
const tar = await import('tar');
await tar.extract({
file: archivePath,
cwd: this.config.localPath,
strip: 1 // Remove the top-level directory from the archive
});
return true;
}
catch (error) {
return false;
}
}
async testCloudConnection() {
try {
const listCommand = new ListObjectsV2Command({
Bucket: this.config.cloudStorage.bucket,
MaxKeys: 1
});
await this.s3Client.send(listCommand);
return { success: true, message: 'Cloud storage connection successful' };
}
catch (error) {
return {
success: false,
message: `Cloud storage connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
async getRecoveryInfo() {
try {
const cloudFiles = await this.listAvailableFiles();
const localFiles = [];
// Check which files exist locally
for (const key of cloudFiles) {
const localPath = join(this.config.localPath, `${key}.json`);
if (existsSync(localPath)) {
localFiles.push(key);
}
}
const missingLocally = cloudFiles.filter(key => !localFiles.includes(key));
return {
cloudFiles: cloudFiles.length,
localFiles: localFiles.length,
missingLocally,
availableForRecovery: cloudFiles
};
}
catch (error) {
return {
cloudFiles: 0,
localFiles: 0,
missingLocally: [],
availableForRecovery: []
};
}
}
}