s3db.js
Version:
Use AWS S3, the world's most reliable document storage, as a database with this ORM.
304 lines (254 loc) • 9.82 kB
JavaScript
import BaseBackupDriver from './base-backup-driver.class.js';
import { createBackupDriver } from './index.js';
import tryFn from '../../concerns/try-fn.js';
/**
* MultiBackupDriver - Manages multiple backup destinations
*
* Configuration:
* - destinations: Array of driver configurations
* - driver: Driver type (filesystem, s3)
* - config: Driver-specific configuration
* - strategy: Backup strategy (default: 'all')
* - 'all': Upload to all destinations (fail if any fails)
* - 'any': Upload to all, succeed if at least one succeeds
* - 'priority': Try destinations in order, stop on first success
* - concurrency: Max concurrent uploads (default: 3)
*/
export default class MultiBackupDriver extends BaseBackupDriver {
constructor(config = {}) {
super({
destinations: [],
strategy: 'all', // 'all', 'any', 'priority'
concurrency: 3,
requireAll: true, // For backward compatibility
...config
});
this.drivers = [];
}
getType() {
return 'multi';
}
async onSetup() {
if (!Array.isArray(this.config.destinations) || this.config.destinations.length === 0) {
throw new Error('MultiBackupDriver: destinations array is required and must not be empty');
}
// Create and setup all driver instances
for (const [index, destConfig] of this.config.destinations.entries()) {
if (!destConfig.driver) {
throw new Error(`MultiBackupDriver: destination[${index}] must have a driver type`);
}
try {
const driver = createBackupDriver(destConfig.driver, destConfig.config || {});
await driver.setup(this.database);
this.drivers.push({
driver,
config: destConfig,
index
});
this.log(`Setup destination ${index}: ${destConfig.driver}`);
} catch (error) {
throw new Error(`Failed to setup destination ${index} (${destConfig.driver}): ${error.message}`);
}
}
// Legacy support for requireAll
if (this.config.requireAll === false) {
this.config.strategy = 'any';
}
this.log(`Initialized with ${this.drivers.length} destinations, strategy: ${this.config.strategy}`);
}
async upload(filePath, backupId, manifest) {
const strategy = this.config.strategy;
const results = [];
const errors = [];
if (strategy === 'priority') {
// Try destinations in order, stop on first success
for (const { driver, config, index } of this.drivers) {
const [ok, err, result] = await tryFn(() =>
driver.upload(filePath, backupId, manifest)
);
if (ok) {
this.log(`Priority upload successful to destination ${index}`);
return [{
...result,
driver: config.driver,
destination: index,
status: 'success'
}];
} else {
errors.push({ destination: index, error: err.message });
this.log(`Priority upload failed to destination ${index}: ${err.message}`);
}
}
throw new Error(`All priority destinations failed: ${errors.map(e => `${e.destination}: ${e.error}`).join('; ')}`);
}
// For 'all' and 'any' strategies, upload to all destinations
const uploadPromises = this.drivers.map(async ({ driver, config, index }) => {
const [ok, err, result] = await tryFn(() =>
driver.upload(filePath, backupId, manifest)
);
if (ok) {
this.log(`Upload successful to destination ${index}`);
return {
...result,
driver: config.driver,
destination: index,
status: 'success'
};
} else {
this.log(`Upload failed to destination ${index}: ${err.message}`);
const errorResult = {
driver: config.driver,
destination: index,
status: 'failed',
error: err.message
};
errors.push(errorResult);
return errorResult;
}
});
// Execute uploads with concurrency limit
const allResults = await this._executeConcurrent(uploadPromises, this.config.concurrency);
const successResults = allResults.filter(r => r.status === 'success');
const failedResults = allResults.filter(r => r.status === 'failed');
if (strategy === 'all' && failedResults.length > 0) {
throw new Error(`Some destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
}
if (strategy === 'any' && successResults.length === 0) {
throw new Error(`All destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
}
return allResults;
}
async download(backupId, targetPath, metadata) {
// Try to download from the first available destination
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
for (const destMetadata of destinations) {
if (destMetadata.status !== 'success') continue;
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
if (!driverInstance) continue;
const [ok, err, result] = await tryFn(() =>
driverInstance.driver.download(backupId, targetPath, destMetadata)
);
if (ok) {
this.log(`Downloaded from destination ${destMetadata.destination}`);
return result;
} else {
this.log(`Download failed from destination ${destMetadata.destination}: ${err.message}`);
}
}
throw new Error(`Failed to download backup from any destination`);
}
async delete(backupId, metadata) {
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
const errors = [];
let successCount = 0;
for (const destMetadata of destinations) {
if (destMetadata.status !== 'success') continue;
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
if (!driverInstance) continue;
const [ok, err] = await tryFn(() =>
driverInstance.driver.delete(backupId, destMetadata)
);
if (ok) {
successCount++;
this.log(`Deleted from destination ${destMetadata.destination}`);
} else {
errors.push(`${destMetadata.destination}: ${err.message}`);
this.log(`Delete failed from destination ${destMetadata.destination}: ${err.message}`);
}
}
if (successCount === 0 && errors.length > 0) {
throw new Error(`Failed to delete from any destination: ${errors.join('; ')}`);
}
if (errors.length > 0) {
this.log(`Partial delete success, some errors: ${errors.join('; ')}`);
}
}
async list(options = {}) {
// Get lists from all destinations and merge/deduplicate
const allLists = await Promise.allSettled(
this.drivers.map(({ driver, index }) =>
driver.list(options).catch(err => {
this.log(`List failed for destination ${index}: ${err.message}`);
return [];
})
)
);
const backupMap = new Map();
// Merge results from all destinations
allLists.forEach((result, index) => {
if (result.status === 'fulfilled') {
result.value.forEach(backup => {
const existing = backupMap.get(backup.id);
if (!existing || new Date(backup.createdAt) > new Date(existing.createdAt)) {
backupMap.set(backup.id, {
...backup,
destinations: existing ? [...(existing.destinations || []), { destination: index, ...backup }] : [{ destination: index, ...backup }]
});
}
});
}
});
const results = Array.from(backupMap.values())
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, options.limit || 50);
return results;
}
async verify(backupId, expectedChecksum, metadata) {
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
// Verify against any successful destination
for (const destMetadata of destinations) {
if (destMetadata.status !== 'success') continue;
const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
if (!driverInstance) continue;
const [ok, , isValid] = await tryFn(() =>
driverInstance.driver.verify(backupId, expectedChecksum, destMetadata)
);
if (ok && isValid) {
this.log(`Verification successful from destination ${destMetadata.destination}`);
return true;
}
}
return false;
}
async cleanup() {
await Promise.all(
this.drivers.map(({ driver }) =>
tryFn(() => driver.cleanup()).catch(() => {})
)
);
}
getStorageInfo() {
return {
...super.getStorageInfo(),
strategy: this.config.strategy,
destinations: this.drivers.map(({ driver, config, index }) => ({
index,
driver: config.driver,
info: driver.getStorageInfo()
}))
};
}
/**
* Execute promises with concurrency limit
* @param {Array} promises - Array of promise functions
* @param {number} concurrency - Max concurrent executions
* @returns {Array} Results in original order
*/
async _executeConcurrent(promises, concurrency) {
const results = new Array(promises.length);
const executing = [];
for (let i = 0; i < promises.length; i++) {
const promise = Promise.resolve(promises[i]).then(result => {
results[i] = result;
return result;
});
executing.push(promise);
if (executing.length >= concurrency) {
await Promise.race(executing);
executing.splice(executing.findIndex(p => p === promise), 1);
}
}
await Promise.all(executing);
return results;
}
}