s3db.js 9.2.0 → 9.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ import BaseBackupDriver from './base-backup-driver.class.js';
2
+ import { createBackupDriver } from './index.js';
3
+ import tryFn from '../../concerns/try-fn.js';
4
+
5
+ /**
6
+ * MultiBackupDriver - Manages multiple backup destinations
7
+ *
8
+ * Configuration:
9
+ * - destinations: Array of driver configurations
10
+ * - driver: Driver type (filesystem, s3)
11
+ * - config: Driver-specific configuration
12
+ * - strategy: Backup strategy (default: 'all')
13
+ * - 'all': Upload to all destinations (fail if any fails)
14
+ * - 'any': Upload to all, succeed if at least one succeeds
15
+ * - 'priority': Try destinations in order, stop on first success
16
+ * - concurrency: Max concurrent uploads (default: 3)
17
+ */
18
+ export default class MultiBackupDriver extends BaseBackupDriver {
19
+ constructor(config = {}) {
20
+ super({
21
+ destinations: [],
22
+ strategy: 'all', // 'all', 'any', 'priority'
23
+ concurrency: 3,
24
+ requireAll: true, // For backward compatibility
25
+ ...config
26
+ });
27
+
28
+ this.drivers = [];
29
+ }
30
+
31
+ getType() {
32
+ return 'multi';
33
+ }
34
+
35
+ async onSetup() {
36
+ if (!Array.isArray(this.config.destinations) || this.config.destinations.length === 0) {
37
+ throw new Error('MultiBackupDriver: destinations array is required and must not be empty');
38
+ }
39
+
40
+ // Create and setup all driver instances
41
+ for (const [index, destConfig] of this.config.destinations.entries()) {
42
+ if (!destConfig.driver) {
43
+ throw new Error(`MultiBackupDriver: destination[${index}] must have a driver type`);
44
+ }
45
+
46
+ try {
47
+ const driver = createBackupDriver(destConfig.driver, destConfig.config || {});
48
+ await driver.setup(this.database);
49
+ this.drivers.push({
50
+ driver,
51
+ config: destConfig,
52
+ index
53
+ });
54
+
55
+ this.log(`Setup destination ${index}: ${destConfig.driver}`);
56
+ } catch (error) {
57
+ throw new Error(`Failed to setup destination ${index} (${destConfig.driver}): ${error.message}`);
58
+ }
59
+ }
60
+
61
+ // Legacy support for requireAll
62
+ if (this.config.requireAll === false) {
63
+ this.config.strategy = 'any';
64
+ }
65
+
66
+ this.log(`Initialized with ${this.drivers.length} destinations, strategy: ${this.config.strategy}`);
67
+ }
68
+
69
+ async upload(filePath, backupId, manifest) {
70
+ const strategy = this.config.strategy;
71
+ const results = [];
72
+ const errors = [];
73
+
74
+ if (strategy === 'priority') {
75
+ // Try destinations in order, stop on first success
76
+ for (const { driver, config, index } of this.drivers) {
77
+ const [ok, err, result] = await tryFn(() =>
78
+ driver.upload(filePath, backupId, manifest)
79
+ );
80
+
81
+ if (ok) {
82
+ this.log(`Priority upload successful to destination ${index}`);
83
+ return [{
84
+ ...result,
85
+ driver: config.driver,
86
+ destination: index,
87
+ status: 'success'
88
+ }];
89
+ } else {
90
+ errors.push({ destination: index, error: err.message });
91
+ this.log(`Priority upload failed to destination ${index}: ${err.message}`);
92
+ }
93
+ }
94
+
95
+ throw new Error(`All priority destinations failed: ${errors.map(e => `${e.destination}: ${e.error}`).join('; ')}`);
96
+ }
97
+
98
+ // For 'all' and 'any' strategies, upload to all destinations
99
+ const uploadPromises = this.drivers.map(async ({ driver, config, index }) => {
100
+ const [ok, err, result] = await tryFn(() =>
101
+ driver.upload(filePath, backupId, manifest)
102
+ );
103
+
104
+ if (ok) {
105
+ this.log(`Upload successful to destination ${index}`);
106
+ return {
107
+ ...result,
108
+ driver: config.driver,
109
+ destination: index,
110
+ status: 'success'
111
+ };
112
+ } else {
113
+ this.log(`Upload failed to destination ${index}: ${err.message}`);
114
+ const errorResult = {
115
+ driver: config.driver,
116
+ destination: index,
117
+ status: 'failed',
118
+ error: err.message
119
+ };
120
+ errors.push(errorResult);
121
+ return errorResult;
122
+ }
123
+ });
124
+
125
+ // Execute uploads with concurrency limit
126
+ const allResults = await this._executeConcurrent(uploadPromises, this.config.concurrency);
127
+ const successResults = allResults.filter(r => r.status === 'success');
128
+ const failedResults = allResults.filter(r => r.status === 'failed');
129
+
130
+ if (strategy === 'all' && failedResults.length > 0) {
131
+ throw new Error(`Some destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
132
+ }
133
+
134
+ if (strategy === 'any' && successResults.length === 0) {
135
+ throw new Error(`All destinations failed: ${failedResults.map(r => `${r.destination}: ${r.error}`).join('; ')}`);
136
+ }
137
+
138
+ return allResults;
139
+ }
140
+
141
+ async download(backupId, targetPath, metadata) {
142
+ // Try to download from the first available destination
143
+ const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
144
+
145
+ for (const destMetadata of destinations) {
146
+ if (destMetadata.status !== 'success') continue;
147
+
148
+ const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
149
+ if (!driverInstance) continue;
150
+
151
+ const [ok, err, result] = await tryFn(() =>
152
+ driverInstance.driver.download(backupId, targetPath, destMetadata)
153
+ );
154
+
155
+ if (ok) {
156
+ this.log(`Downloaded from destination ${destMetadata.destination}`);
157
+ return result;
158
+ } else {
159
+ this.log(`Download failed from destination ${destMetadata.destination}: ${err.message}`);
160
+ }
161
+ }
162
+
163
+ throw new Error(`Failed to download backup from any destination`);
164
+ }
165
+
166
+ async delete(backupId, metadata) {
167
+ const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
168
+ const errors = [];
169
+ let successCount = 0;
170
+
171
+ for (const destMetadata of destinations) {
172
+ if (destMetadata.status !== 'success') continue;
173
+
174
+ const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
175
+ if (!driverInstance) continue;
176
+
177
+ const [ok, err] = await tryFn(() =>
178
+ driverInstance.driver.delete(backupId, destMetadata)
179
+ );
180
+
181
+ if (ok) {
182
+ successCount++;
183
+ this.log(`Deleted from destination ${destMetadata.destination}`);
184
+ } else {
185
+ errors.push(`${destMetadata.destination}: ${err.message}`);
186
+ this.log(`Delete failed from destination ${destMetadata.destination}: ${err.message}`);
187
+ }
188
+ }
189
+
190
+ if (successCount === 0 && errors.length > 0) {
191
+ throw new Error(`Failed to delete from any destination: ${errors.join('; ')}`);
192
+ }
193
+
194
+ if (errors.length > 0) {
195
+ this.log(`Partial delete success, some errors: ${errors.join('; ')}`);
196
+ }
197
+ }
198
+
199
+ async list(options = {}) {
200
+ // Get lists from all destinations and merge/deduplicate
201
+ const allLists = await Promise.allSettled(
202
+ this.drivers.map(({ driver, index }) =>
203
+ driver.list(options).catch(err => {
204
+ this.log(`List failed for destination ${index}: ${err.message}`);
205
+ return [];
206
+ })
207
+ )
208
+ );
209
+
210
+ const backupMap = new Map();
211
+
212
+ // Merge results from all destinations
213
+ allLists.forEach((result, index) => {
214
+ if (result.status === 'fulfilled') {
215
+ result.value.forEach(backup => {
216
+ const existing = backupMap.get(backup.id);
217
+ if (!existing || new Date(backup.createdAt) > new Date(existing.createdAt)) {
218
+ backupMap.set(backup.id, {
219
+ ...backup,
220
+ destinations: existing ? [...(existing.destinations || []), { destination: index, ...backup }] : [{ destination: index, ...backup }]
221
+ });
222
+ }
223
+ });
224
+ }
225
+ });
226
+
227
+ const results = Array.from(backupMap.values())
228
+ .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
229
+ .slice(0, options.limit || 50);
230
+
231
+ return results;
232
+ }
233
+
234
+ async verify(backupId, expectedChecksum, metadata) {
235
+ const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
236
+
237
+ // Verify against any successful destination
238
+ for (const destMetadata of destinations) {
239
+ if (destMetadata.status !== 'success') continue;
240
+
241
+ const driverInstance = this.drivers.find(d => d.index === destMetadata.destination);
242
+ if (!driverInstance) continue;
243
+
244
+ const [ok, , isValid] = await tryFn(() =>
245
+ driverInstance.driver.verify(backupId, expectedChecksum, destMetadata)
246
+ );
247
+
248
+ if (ok && isValid) {
249
+ this.log(`Verification successful from destination ${destMetadata.destination}`);
250
+ return true;
251
+ }
252
+ }
253
+
254
+ return false;
255
+ }
256
+
257
+ async cleanup() {
258
+ await Promise.all(
259
+ this.drivers.map(({ driver }) =>
260
+ tryFn(() => driver.cleanup()).catch(() => {})
261
+ )
262
+ );
263
+ }
264
+
265
+ getStorageInfo() {
266
+ return {
267
+ ...super.getStorageInfo(),
268
+ strategy: this.config.strategy,
269
+ destinations: this.drivers.map(({ driver, config, index }) => ({
270
+ index,
271
+ driver: config.driver,
272
+ info: driver.getStorageInfo()
273
+ }))
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Execute promises with concurrency limit
279
+ * @param {Array} promises - Array of promise functions
280
+ * @param {number} concurrency - Max concurrent executions
281
+ * @returns {Array} Results in original order
282
+ */
283
+ async _executeConcurrent(promises, concurrency) {
284
+ const results = new Array(promises.length);
285
+ const executing = [];
286
+
287
+ for (let i = 0; i < promises.length; i++) {
288
+ const promise = Promise.resolve(promises[i]).then(result => {
289
+ results[i] = result;
290
+ return result;
291
+ });
292
+
293
+ executing.push(promise);
294
+
295
+ if (executing.length >= concurrency) {
296
+ await Promise.race(executing);
297
+ executing.splice(executing.findIndex(p => p === promise), 1);
298
+ }
299
+ }
300
+
301
+ await Promise.all(executing);
302
+ return results;
303
+ }
304
+ }
@@ -0,0 +1,313 @@
1
+ import BaseBackupDriver from './base-backup-driver.class.js';
2
+ import { createReadStream } from 'fs';
3
+ import { stat } from 'fs/promises';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
6
+ import tryFn from '../../concerns/try-fn.js';
7
+
8
+ /**
9
+ * S3BackupDriver - Stores backups in S3-compatible storage
10
+ *
11
+ * Configuration:
12
+ * - bucket: S3 bucket name (optional, uses database bucket if not specified)
13
+ * - path: Key prefix for backups (supports template variables)
14
+ * - storageClass: S3 storage class (default: STANDARD_IA)
15
+ * - serverSideEncryption: S3 server-side encryption (default: AES256)
16
+ * - client: Custom S3 client (optional, uses database client if not specified)
17
+ */
18
+ export default class S3BackupDriver extends BaseBackupDriver {
19
+ constructor(config = {}) {
20
+ super({
21
+ bucket: null, // Will use database bucket if not specified
22
+ path: 'backups/{date}/',
23
+ storageClass: 'STANDARD_IA',
24
+ serverSideEncryption: 'AES256',
25
+ client: null, // Will use database client if not specified
26
+ ...config
27
+ });
28
+ }
29
+
30
+ getType() {
31
+ return 's3';
32
+ }
33
+
34
+ async onSetup() {
35
+ // Use database client if not provided
36
+ if (!this.config.client) {
37
+ this.config.client = this.database.client;
38
+ }
39
+
40
+ // Use database bucket if not specified
41
+ if (!this.config.bucket) {
42
+ this.config.bucket = this.database.bucket;
43
+ }
44
+
45
+ if (!this.config.client) {
46
+ throw new Error('S3BackupDriver: client is required (either via config or database)');
47
+ }
48
+
49
+ if (!this.config.bucket) {
50
+ throw new Error('S3BackupDriver: bucket is required (either via config or database)');
51
+ }
52
+
53
+ this.log(`Initialized with bucket: ${this.config.bucket}, path: ${this.config.path}`);
54
+ }
55
+
56
+ /**
57
+ * Resolve S3 key template variables
58
+ * @param {string} backupId - Backup identifier
59
+ * @param {Object} manifest - Backup manifest
60
+ * @returns {string} Resolved S3 key
61
+ */
62
+ resolveKey(backupId, manifest = {}) {
63
+ const now = new Date();
64
+ const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
65
+ const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '-'); // HH-MM-SS
66
+
67
+ const basePath = this.config.path
68
+ .replace('{date}', dateStr)
69
+ .replace('{time}', timeStr)
70
+ .replace('{year}', now.getFullYear().toString())
71
+ .replace('{month}', (now.getMonth() + 1).toString().padStart(2, '0'))
72
+ .replace('{day}', now.getDate().toString().padStart(2, '0'))
73
+ .replace('{backupId}', backupId)
74
+ .replace('{type}', manifest.type || 'backup');
75
+
76
+ return path.posix.join(basePath, `${backupId}.backup`);
77
+ }
78
+
79
+ resolveManifestKey(backupId, manifest = {}) {
80
+ return this.resolveKey(backupId, manifest).replace('.backup', '.manifest.json');
81
+ }
82
+
83
+ async upload(filePath, backupId, manifest) {
84
+ const backupKey = this.resolveKey(backupId, manifest);
85
+ const manifestKey = this.resolveManifestKey(backupId, manifest);
86
+
87
+ // Get file size
88
+ const [statOk, , stats] = await tryFn(() => stat(filePath));
89
+ const fileSize = statOk ? stats.size : 0;
90
+
91
+ // Upload backup file
92
+ const [uploadOk, uploadErr] = await tryFn(async () => {
93
+ const fileStream = createReadStream(filePath);
94
+
95
+ return await this.config.client.uploadObject({
96
+ bucket: this.config.bucket,
97
+ key: backupKey,
98
+ body: fileStream,
99
+ contentLength: fileSize,
100
+ metadata: {
101
+ 'backup-id': backupId,
102
+ 'backup-type': manifest.type || 'backup',
103
+ 'created-at': new Date().toISOString()
104
+ },
105
+ storageClass: this.config.storageClass,
106
+ serverSideEncryption: this.config.serverSideEncryption
107
+ });
108
+ });
109
+
110
+ if (!uploadOk) {
111
+ throw new Error(`Failed to upload backup file: ${uploadErr.message}`);
112
+ }
113
+
114
+ // Upload manifest
115
+ const [manifestOk, manifestErr] = await tryFn(() =>
116
+ this.config.client.uploadObject({
117
+ bucket: this.config.bucket,
118
+ key: manifestKey,
119
+ body: JSON.stringify(manifest, null, 2),
120
+ contentType: 'application/json',
121
+ metadata: {
122
+ 'backup-id': backupId,
123
+ 'manifest-for': backupKey
124
+ },
125
+ storageClass: this.config.storageClass,
126
+ serverSideEncryption: this.config.serverSideEncryption
127
+ })
128
+ );
129
+
130
+ if (!manifestOk) {
131
+ // Clean up backup file if manifest upload fails
132
+ await tryFn(() => this.config.client.deleteObject({
133
+ bucket: this.config.bucket,
134
+ key: backupKey
135
+ }));
136
+ throw new Error(`Failed to upload manifest: ${manifestErr.message}`);
137
+ }
138
+
139
+ this.log(`Uploaded backup ${backupId} to s3://${this.config.bucket}/${backupKey} (${fileSize} bytes)`);
140
+
141
+ return {
142
+ bucket: this.config.bucket,
143
+ key: backupKey,
144
+ manifestKey,
145
+ size: fileSize,
146
+ storageClass: this.config.storageClass,
147
+ uploadedAt: new Date().toISOString(),
148
+ etag: uploadOk?.ETag
149
+ };
150
+ }
151
+
152
+ async download(backupId, targetPath, metadata) {
153
+ const backupKey = metadata.key || this.resolveKey(backupId, metadata);
154
+
155
+ const [downloadOk, downloadErr] = await tryFn(() =>
156
+ this.config.client.downloadObject({
157
+ bucket: this.config.bucket,
158
+ key: backupKey,
159
+ filePath: targetPath
160
+ })
161
+ );
162
+
163
+ if (!downloadOk) {
164
+ throw new Error(`Failed to download backup: ${downloadErr.message}`);
165
+ }
166
+
167
+ this.log(`Downloaded backup ${backupId} from s3://${this.config.bucket}/${backupKey} to ${targetPath}`);
168
+ return targetPath;
169
+ }
170
+
171
+ async delete(backupId, metadata) {
172
+ const backupKey = metadata.key || this.resolveKey(backupId, metadata);
173
+ const manifestKey = metadata.manifestKey || this.resolveManifestKey(backupId, metadata);
174
+
175
+ // Delete backup file
176
+ const [deleteBackupOk] = await tryFn(() =>
177
+ this.config.client.deleteObject({
178
+ bucket: this.config.bucket,
179
+ key: backupKey
180
+ })
181
+ );
182
+
183
+ // Delete manifest
184
+ const [deleteManifestOk] = await tryFn(() =>
185
+ this.config.client.deleteObject({
186
+ bucket: this.config.bucket,
187
+ key: manifestKey
188
+ })
189
+ );
190
+
191
+ if (!deleteBackupOk && !deleteManifestOk) {
192
+ throw new Error(`Failed to delete backup objects for ${backupId}`);
193
+ }
194
+
195
+ this.log(`Deleted backup ${backupId} from S3`);
196
+ }
197
+
198
+ async list(options = {}) {
199
+ const { limit = 50, prefix = '' } = options;
200
+ const searchPrefix = this.config.path.replace(/\{[^}]+\}/g, '');
201
+
202
+ const [listOk, listErr, response] = await tryFn(() =>
203
+ this.config.client.listObjects({
204
+ bucket: this.config.bucket,
205
+ prefix: searchPrefix,
206
+ maxKeys: limit * 2 // Get more to account for manifest files
207
+ })
208
+ );
209
+
210
+ if (!listOk) {
211
+ this.log(`Error listing S3 objects: ${listErr.message}`);
212
+ return [];
213
+ }
214
+
215
+ const manifestObjects = (response.Contents || [])
216
+ .filter(obj => obj.Key.endsWith('.manifest.json'))
217
+ .filter(obj => !prefix || obj.Key.includes(prefix));
218
+
219
+ const results = [];
220
+
221
+ for (const obj of manifestObjects.slice(0, limit)) {
222
+ const [manifestOk, , manifestContent] = await tryFn(() =>
223
+ this.config.client.getObject({
224
+ bucket: this.config.bucket,
225
+ key: obj.Key
226
+ })
227
+ );
228
+
229
+ if (manifestOk) {
230
+ try {
231
+ const manifest = JSON.parse(manifestContent);
232
+ const backupId = path.basename(obj.Key, '.manifest.json');
233
+
234
+ results.push({
235
+ id: backupId,
236
+ bucket: this.config.bucket,
237
+ key: obj.Key.replace('.manifest.json', '.backup'),
238
+ manifestKey: obj.Key,
239
+ size: obj.Size,
240
+ lastModified: obj.LastModified,
241
+ storageClass: obj.StorageClass,
242
+ createdAt: manifest.createdAt || obj.LastModified,
243
+ ...manifest
244
+ });
245
+ } catch (parseErr) {
246
+ this.log(`Failed to parse manifest ${obj.Key}: ${parseErr.message}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ // Sort by creation time (newest first)
252
+ results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
253
+
254
+ return results;
255
+ }
256
+
257
+ async verify(backupId, expectedChecksum, metadata) {
258
+ const backupKey = metadata.key || this.resolveKey(backupId, metadata);
259
+
260
+ const [verifyOk, verifyErr] = await tryFn(async () => {
261
+ // Get object metadata to check ETag
262
+ const headResponse = await this.config.client.headObject({
263
+ bucket: this.config.bucket,
264
+ key: backupKey
265
+ });
266
+
267
+ // For single-part uploads, ETag is the MD5 hash
268
+ // For multipart uploads, ETag has a suffix like "-2"
269
+ const etag = headResponse.ETag?.replace(/"/g, '');
270
+
271
+ if (etag && !etag.includes('-')) {
272
+ // Single-part upload, ETag is MD5
273
+ const expectedMd5 = crypto.createHash('md5').update(expectedChecksum).digest('hex');
274
+ return etag === expectedMd5;
275
+ } else {
276
+ // For multipart uploads or SHA256 comparison, download and verify
277
+ const [streamOk, , stream] = await tryFn(() =>
278
+ this.config.client.getObjectStream({
279
+ bucket: this.config.bucket,
280
+ key: backupKey
281
+ })
282
+ );
283
+
284
+ if (!streamOk) return false;
285
+
286
+ const hash = crypto.createHash('sha256');
287
+ for await (const chunk of stream) {
288
+ hash.update(chunk);
289
+ }
290
+
291
+ const actualChecksum = hash.digest('hex');
292
+ return actualChecksum === expectedChecksum;
293
+ }
294
+ });
295
+
296
+ if (!verifyOk) {
297
+ this.log(`Verification failed for ${backupId}: ${verifyErr?.message || 'checksum mismatch'}`);
298
+ return false;
299
+ }
300
+
301
+ return true;
302
+ }
303
+
304
+ getStorageInfo() {
305
+ return {
306
+ ...super.getStorageInfo(),
307
+ bucket: this.config.bucket,
308
+ path: this.config.path,
309
+ storageClass: this.config.storageClass,
310
+ serverSideEncryption: this.config.serverSideEncryption
311
+ };
312
+ }
313
+ }