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.
- package/PLUGINS.md +453 -102
- package/README.md +31 -2
- package/dist/s3db.cjs.js +1240 -565
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1240 -565
- package/dist/s3db.es.js.map +1 -1
- package/package.json +5 -5
- package/src/concerns/async-event-emitter.js +46 -0
- package/src/database.class.js +23 -0
- package/src/plugins/backup/base-backup-driver.class.js +119 -0
- package/src/plugins/backup/filesystem-backup-driver.class.js +254 -0
- package/src/plugins/backup/index.js +85 -0
- package/src/plugins/backup/multi-backup-driver.class.js +304 -0
- package/src/plugins/backup/s3-backup-driver.class.js +313 -0
- package/src/plugins/backup.plugin.js +375 -729
- package/src/plugins/backup.plugin.js.backup +1026 -0
- package/src/plugins/scheduler.plugin.js +0 -1
- package/src/resource.class.js +156 -41
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Plugin from "./plugin.class.js";
|
|
2
2
|
import tryFn from "../concerns/try-fn.js";
|
|
3
|
+
import { createBackupDriver, validateBackupConfig } from "./backup/index.js";
|
|
3
4
|
import { createWriteStream, createReadStream } from 'fs';
|
|
4
5
|
import zlib from 'node:zlib';
|
|
5
6
|
import { pipeline } from 'stream/promises';
|
|
@@ -10,87 +11,89 @@ import crypto from 'crypto';
|
|
|
10
11
|
/**
|
|
11
12
|
* BackupPlugin - Automated Database Backup System
|
|
12
13
|
*
|
|
13
|
-
* Provides comprehensive backup functionality with
|
|
14
|
+
* Provides comprehensive backup functionality with configurable drivers,
|
|
14
15
|
* retention policies, and restoration capabilities.
|
|
15
16
|
*
|
|
16
|
-
* ===
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
* -
|
|
20
|
-
* - Compression and encryption
|
|
21
|
-
* - Backup verification and integrity checks
|
|
22
|
-
* - Scheduled backups with cron expressions
|
|
23
|
-
* - Parallel uploads for performance
|
|
24
|
-
* - Backup metadata and restoration
|
|
17
|
+
* === Driver-Based Architecture ===
|
|
18
|
+
* Uses the standard S3DB plugin driver pattern:
|
|
19
|
+
* - driver: Driver type (filesystem, s3, multi)
|
|
20
|
+
* - config: Driver-specific configuration
|
|
25
21
|
*
|
|
26
|
-
* === Configuration
|
|
22
|
+
* === Configuration Examples ===
|
|
27
23
|
*
|
|
24
|
+
* // Filesystem backup
|
|
28
25
|
* new BackupPlugin({
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* }
|
|
34
|
-
*
|
|
35
|
-
* // Retention policy (Grandfather-Father-Son)
|
|
36
|
-
* retention: {
|
|
37
|
-
* daily: 7, // Keep 7 daily backups
|
|
38
|
-
* weekly: 4, // Keep 4 weekly backups
|
|
39
|
-
* monthly: 12, // Keep 12 monthly backups
|
|
40
|
-
* yearly: 3 // Keep 3 yearly backups
|
|
41
|
-
* },
|
|
42
|
-
*
|
|
43
|
-
* // Multiple backup destinations
|
|
44
|
-
* destinations: [
|
|
45
|
-
* {
|
|
46
|
-
* type: 's3',
|
|
47
|
-
* bucket: 'my-backups',
|
|
48
|
-
* path: 'database/{date}/',
|
|
49
|
-
* encryption: true,
|
|
50
|
-
* storageClass: 'STANDARD_IA'
|
|
51
|
-
* },
|
|
52
|
-
* {
|
|
53
|
-
* type: 'filesystem',
|
|
54
|
-
* path: '/var/backups/s3db/',
|
|
55
|
-
* compression: 'gzip'
|
|
56
|
-
* }
|
|
57
|
-
* ],
|
|
58
|
-
*
|
|
59
|
-
* // Backup configuration
|
|
60
|
-
* compression: 'gzip', // none, gzip, brotli, deflate
|
|
61
|
-
* encryption: {
|
|
62
|
-
* algorithm: 'AES-256-GCM',
|
|
63
|
-
* key: process.env.BACKUP_ENCRYPTION_KEY
|
|
64
|
-
* },
|
|
65
|
-
* verification: true, // Verify backup integrity
|
|
66
|
-
* parallelism: 4, // Parallel upload streams
|
|
67
|
-
*
|
|
68
|
-
* // Resource filtering
|
|
69
|
-
* include: ['users', 'orders'], // Only these resources
|
|
70
|
-
* exclude: ['temp_*', 'cache_*'], // Exclude patterns
|
|
71
|
-
*
|
|
72
|
-
* // Metadata
|
|
73
|
-
* backupMetadataResource: 'backup_metadata',
|
|
74
|
-
*
|
|
75
|
-
* // Hooks
|
|
76
|
-
* onBackupStart: (type, config) => console.log(`Starting ${type} backup`),
|
|
77
|
-
* onBackupComplete: (type, stats) => notifySlack(`Backup complete: ${stats}`)
|
|
26
|
+
* driver: 'filesystem',
|
|
27
|
+
* config: {
|
|
28
|
+
* path: '/var/backups/s3db/{date}/',
|
|
29
|
+
* compression: 'gzip'
|
|
30
|
+
* }
|
|
78
31
|
* });
|
|
32
|
+
*
|
|
33
|
+
* // S3 backup
|
|
34
|
+
* new BackupPlugin({
|
|
35
|
+
* driver: 's3',
|
|
36
|
+
* config: {
|
|
37
|
+
* bucket: 'my-backup-bucket',
|
|
38
|
+
* path: 'database/{date}/',
|
|
39
|
+
* storageClass: 'STANDARD_IA'
|
|
40
|
+
* }
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // Multiple destinations
|
|
44
|
+
* new BackupPlugin({
|
|
45
|
+
* driver: 'multi',
|
|
46
|
+
* config: {
|
|
47
|
+
* strategy: 'all', // 'all', 'any', 'priority'
|
|
48
|
+
* destinations: [
|
|
49
|
+
* {
|
|
50
|
+
* driver: 'filesystem',
|
|
51
|
+
* config: { path: '/var/backups/s3db/' }
|
|
52
|
+
* },
|
|
53
|
+
* {
|
|
54
|
+
* driver: 's3',
|
|
55
|
+
* config: {
|
|
56
|
+
* bucket: 'remote-backups',
|
|
57
|
+
* storageClass: 'GLACIER'
|
|
58
|
+
* }
|
|
59
|
+
* }
|
|
60
|
+
* ]
|
|
61
|
+
* }
|
|
62
|
+
* });
|
|
63
|
+
*
|
|
64
|
+
* === Additional Plugin Options ===
|
|
65
|
+
* - schedule: Cron expressions for automated backups
|
|
66
|
+
* - retention: Backup retention policy (GFS)
|
|
67
|
+
* - compression: Compression type (gzip, brotli, none)
|
|
68
|
+
* - encryption: Encryption configuration
|
|
69
|
+
* - verification: Enable backup verification
|
|
70
|
+
* - backupMetadataResource: Resource name for metadata
|
|
79
71
|
*/
|
|
80
72
|
export class BackupPlugin extends Plugin {
|
|
81
73
|
constructor(options = {}) {
|
|
82
74
|
super();
|
|
83
75
|
|
|
76
|
+
// Extract driver configuration
|
|
77
|
+
this.driverName = options.driver || 'filesystem';
|
|
78
|
+
this.driverConfig = options.config || {};
|
|
79
|
+
|
|
84
80
|
this.config = {
|
|
81
|
+
// Legacy destinations support (will be converted to multi driver)
|
|
82
|
+
destinations: options.destinations || null,
|
|
83
|
+
|
|
84
|
+
// Scheduling configuration
|
|
85
85
|
schedule: options.schedule || {},
|
|
86
|
+
|
|
87
|
+
// Retention policy (Grandfather-Father-Son)
|
|
86
88
|
retention: {
|
|
87
89
|
daily: 7,
|
|
88
|
-
weekly: 4,
|
|
90
|
+
weekly: 4,
|
|
89
91
|
monthly: 12,
|
|
90
92
|
yearly: 3,
|
|
91
93
|
...options.retention
|
|
92
94
|
},
|
|
93
|
-
|
|
95
|
+
|
|
96
|
+
// Backup options
|
|
94
97
|
compression: options.compression || 'gzip',
|
|
95
98
|
encryption: options.encryption || null,
|
|
96
99
|
verification: options.verification !== false,
|
|
@@ -100,52 +103,86 @@ export class BackupPlugin extends Plugin {
|
|
|
100
103
|
backupMetadataResource: options.backupMetadataResource || 'backup_metadata',
|
|
101
104
|
tempDir: options.tempDir || './tmp/backups',
|
|
102
105
|
verbose: options.verbose || false,
|
|
106
|
+
|
|
107
|
+
// Hooks
|
|
103
108
|
onBackupStart: options.onBackupStart || null,
|
|
104
109
|
onBackupComplete: options.onBackupComplete || null,
|
|
105
110
|
onBackupError: options.onBackupError || null,
|
|
106
|
-
|
|
111
|
+
onRestoreStart: options.onRestoreStart || null,
|
|
112
|
+
onRestoreComplete: options.onRestoreComplete || null,
|
|
113
|
+
onRestoreError: options.onRestoreError || null
|
|
107
114
|
};
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
this.scheduledJobs = new Map();
|
|
115
|
+
|
|
116
|
+
this.driver = null;
|
|
111
117
|
this.activeBackups = new Set();
|
|
112
118
|
|
|
119
|
+
// Handle legacy destinations format
|
|
120
|
+
this._handleLegacyDestinations();
|
|
121
|
+
|
|
122
|
+
// Validate driver configuration (after legacy conversion)
|
|
123
|
+
validateBackupConfig(this.driverName, this.driverConfig);
|
|
124
|
+
|
|
113
125
|
this._validateConfiguration();
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Convert legacy destinations format to multi driver format
|
|
130
|
+
*/
|
|
131
|
+
_handleLegacyDestinations() {
|
|
132
|
+
if (this.config.destinations && Array.isArray(this.config.destinations)) {
|
|
133
|
+
// Convert legacy format to multi driver
|
|
134
|
+
this.driverName = 'multi';
|
|
135
|
+
this.driverConfig = {
|
|
136
|
+
strategy: 'all',
|
|
137
|
+
destinations: this.config.destinations.map(dest => {
|
|
138
|
+
const { type, ...config } = dest; // Extract type and get the rest as config
|
|
139
|
+
return {
|
|
140
|
+
driver: type,
|
|
141
|
+
config
|
|
142
|
+
};
|
|
143
|
+
})
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Clear legacy destinations
|
|
147
|
+
this.config.destinations = null;
|
|
148
|
+
|
|
149
|
+
if (this.config.verbose) {
|
|
150
|
+
console.log('[BackupPlugin] Converted legacy destinations format to multi driver');
|
|
124
151
|
}
|
|
125
152
|
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_validateConfiguration() {
|
|
156
|
+
// Driver validation is done in constructor
|
|
126
157
|
|
|
127
158
|
if (this.config.encryption && (!this.config.encryption.key || !this.config.encryption.algorithm)) {
|
|
128
159
|
throw new Error('BackupPlugin: Encryption requires both key and algorithm');
|
|
129
160
|
}
|
|
161
|
+
|
|
162
|
+
if (this.config.compression && !['none', 'gzip', 'brotli', 'deflate'].includes(this.config.compression)) {
|
|
163
|
+
throw new Error('BackupPlugin: Invalid compression type. Use: none, gzip, brotli, deflate');
|
|
164
|
+
}
|
|
130
165
|
}
|
|
131
166
|
|
|
132
|
-
async
|
|
133
|
-
|
|
167
|
+
async onSetup() {
|
|
168
|
+
// Create backup driver instance
|
|
169
|
+
this.driver = createBackupDriver(this.driverName, this.driverConfig);
|
|
170
|
+
await this.driver.setup(this.database);
|
|
171
|
+
|
|
172
|
+
// Create temporary directory
|
|
173
|
+
await mkdir(this.config.tempDir, { recursive: true });
|
|
134
174
|
|
|
135
175
|
// Create backup metadata resource
|
|
136
176
|
await this._createBackupMetadataResource();
|
|
137
177
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
// Setup scheduled backups
|
|
142
|
-
if (Object.keys(this.config.schedule).length > 0) {
|
|
143
|
-
await this._setupScheduledBackups();
|
|
178
|
+
if (this.config.verbose) {
|
|
179
|
+
const storageInfo = this.driver.getStorageInfo();
|
|
180
|
+
console.log(`[BackupPlugin] Initialized with driver: ${storageInfo.type}`);
|
|
144
181
|
}
|
|
145
182
|
|
|
146
183
|
this.emit('initialized', {
|
|
147
|
-
|
|
148
|
-
|
|
184
|
+
driver: this.driver.getType(),
|
|
185
|
+
config: this.driver.getStorageInfo()
|
|
149
186
|
});
|
|
150
187
|
}
|
|
151
188
|
|
|
@@ -157,7 +194,7 @@ export class BackupPlugin extends Plugin {
|
|
|
157
194
|
type: 'string|required',
|
|
158
195
|
timestamp: 'number|required',
|
|
159
196
|
resources: 'json|required',
|
|
160
|
-
|
|
197
|
+
driverInfo: 'json|required', // Store driver info instead of destinations
|
|
161
198
|
size: 'number|default:0',
|
|
162
199
|
compressed: 'boolean|default:false',
|
|
163
200
|
encrypted: 'boolean|default:false',
|
|
@@ -168,129 +205,94 @@ export class BackupPlugin extends Plugin {
|
|
|
168
205
|
createdAt: 'string|required'
|
|
169
206
|
},
|
|
170
207
|
behavior: 'body-overflow',
|
|
171
|
-
|
|
172
|
-
byType: { fields: { type: 'string' } },
|
|
173
|
-
byDate: { fields: { createdAt: 'string|maxlength:10' } }
|
|
174
|
-
}
|
|
208
|
+
timestamps: true
|
|
175
209
|
}));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async _ensureTempDirectory() {
|
|
179
|
-
const [ok] = await tryFn(() => mkdir(this.config.tempDir, { recursive: true }));
|
|
180
|
-
}
|
|
181
210
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// For now, just log the scheduled backups
|
|
185
|
-
if (this.config.verbose) {
|
|
186
|
-
console.log('[BackupPlugin] Scheduled backups configured:', this.config.schedule);
|
|
211
|
+
if (!ok && this.config.verbose) {
|
|
212
|
+
console.log(`[BackupPlugin] Backup metadata resource '${this.config.backupMetadataResource}' already exists`);
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
/**
|
|
191
|
-
*
|
|
217
|
+
* Create a backup
|
|
218
|
+
* @param {string} type - Backup type ('full' or 'incremental')
|
|
219
|
+
* @param {Object} options - Backup options
|
|
220
|
+
* @returns {Object} Backup result
|
|
192
221
|
*/
|
|
193
222
|
async backup(type = 'full', options = {}) {
|
|
194
|
-
const backupId =
|
|
195
|
-
|
|
196
|
-
if (this.activeBackups.has(backupId)) {
|
|
197
|
-
throw new Error(`Backup ${backupId} already in progress`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
this.activeBackups.add(backupId);
|
|
223
|
+
const backupId = this._generateBackupId(type);
|
|
224
|
+
const startTime = Date.now();
|
|
201
225
|
|
|
202
226
|
try {
|
|
203
|
-
|
|
227
|
+
this.activeBackups.add(backupId);
|
|
204
228
|
|
|
205
229
|
// Execute onBackupStart hook
|
|
206
230
|
if (this.config.onBackupStart) {
|
|
207
|
-
await this._executeHook(this.config.onBackupStart, type, { backupId
|
|
231
|
+
await this._executeHook(this.config.onBackupStart, type, { backupId });
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
this.emit('backup_start', { id: backupId, type });
|
|
211
235
|
|
|
212
|
-
// Create backup metadata
|
|
236
|
+
// Create backup metadata
|
|
213
237
|
const metadata = await this._createBackupMetadata(backupId, type);
|
|
214
238
|
|
|
215
|
-
// Get resources to backup
|
|
216
|
-
const resources = await this._getResourcesToBackup();
|
|
217
|
-
|
|
218
239
|
// Create temporary backup directory
|
|
219
240
|
const tempBackupDir = path.join(this.config.tempDir, backupId);
|
|
220
241
|
await mkdir(tempBackupDir, { recursive: true });
|
|
221
242
|
|
|
222
|
-
let totalSize = 0;
|
|
223
|
-
const resourceFiles = new Map();
|
|
224
|
-
|
|
225
243
|
try {
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
const resourceData = await this._backupResource(resourceName, type);
|
|
229
|
-
const filePath = path.join(tempBackupDir, `${resourceName}.json`);
|
|
230
|
-
|
|
231
|
-
await writeFile(filePath, JSON.stringify(resourceData, null, 2));
|
|
232
|
-
const stats = await stat(filePath);
|
|
233
|
-
totalSize += stats.size;
|
|
234
|
-
resourceFiles.set(resourceName, { path: filePath, size: stats.size });
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Create manifest
|
|
238
|
-
const manifest = {
|
|
239
|
-
id: backupId,
|
|
240
|
-
type,
|
|
241
|
-
timestamp: Date.now(),
|
|
242
|
-
resources: Array.from(resourceFiles.keys()),
|
|
243
|
-
totalSize,
|
|
244
|
-
compression: this.config.compression,
|
|
245
|
-
encryption: !!this.config.encryption
|
|
246
|
-
};
|
|
244
|
+
// Create backup manifest
|
|
245
|
+
const manifest = await this._createBackupManifest(type, options);
|
|
247
246
|
|
|
248
|
-
|
|
249
|
-
await
|
|
247
|
+
// Export resources to backup files
|
|
248
|
+
const exportedFiles = await this._exportResources(manifest.resources, tempBackupDir, type);
|
|
250
249
|
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
finalPath = await this._compressBackup(tempBackupDir, backupId);
|
|
250
|
+
// Check if we have any files to backup
|
|
251
|
+
if (exportedFiles.length === 0) {
|
|
252
|
+
throw new Error('No resources were exported for backup');
|
|
255
253
|
}
|
|
256
254
|
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
255
|
+
// Create archive if compression is enabled
|
|
256
|
+
let finalPath;
|
|
257
|
+
let totalSize = 0;
|
|
261
258
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// If compressed or encrypted, finalPath is a file
|
|
266
|
-
checksum = await this._calculateChecksum(finalPath);
|
|
259
|
+
if (this.config.compression !== 'none') {
|
|
260
|
+
finalPath = path.join(tempBackupDir, `${backupId}.tar.gz`);
|
|
261
|
+
totalSize = await this._createCompressedArchive(exportedFiles, finalPath);
|
|
267
262
|
} else {
|
|
268
|
-
|
|
269
|
-
|
|
263
|
+
finalPath = exportedFiles[0]; // For single file backups
|
|
264
|
+
const [statOk, , stats] = await tryFn(() => stat(finalPath));
|
|
265
|
+
totalSize = statOk ? stats.size : 0;
|
|
270
266
|
}
|
|
271
267
|
|
|
272
|
-
//
|
|
273
|
-
const
|
|
268
|
+
// Generate checksum
|
|
269
|
+
const checksum = await this._generateChecksum(finalPath);
|
|
270
|
+
|
|
271
|
+
// Upload using driver
|
|
272
|
+
const uploadResult = await this.driver.upload(finalPath, backupId, manifest);
|
|
274
273
|
|
|
275
274
|
// Verify backup if enabled
|
|
276
275
|
if (this.config.verification) {
|
|
277
|
-
await this.
|
|
276
|
+
const isValid = await this.driver.verify(backupId, checksum, uploadResult);
|
|
277
|
+
if (!isValid) {
|
|
278
|
+
throw new Error('Backup verification failed');
|
|
279
|
+
}
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
const duration = Date.now() - startTime;
|
|
281
283
|
|
|
282
284
|
// Update metadata
|
|
283
|
-
await this._updateBackupMetadata(
|
|
285
|
+
await this._updateBackupMetadata(backupId, {
|
|
284
286
|
status: 'completed',
|
|
285
287
|
size: totalSize,
|
|
286
288
|
checksum,
|
|
287
|
-
|
|
289
|
+
driverInfo: uploadResult,
|
|
288
290
|
duration
|
|
289
291
|
});
|
|
290
292
|
|
|
291
293
|
// Execute onBackupComplete hook
|
|
292
294
|
if (this.config.onBackupComplete) {
|
|
293
|
-
const stats = { backupId, type, size: totalSize, duration,
|
|
295
|
+
const stats = { backupId, type, size: totalSize, duration, driverInfo: uploadResult };
|
|
294
296
|
await this._executeHook(this.config.onBackupComplete, type, stats);
|
|
295
297
|
}
|
|
296
298
|
|
|
@@ -299,7 +301,7 @@ export class BackupPlugin extends Plugin {
|
|
|
299
301
|
type,
|
|
300
302
|
size: totalSize,
|
|
301
303
|
duration,
|
|
302
|
-
|
|
304
|
+
driverInfo: uploadResult
|
|
303
305
|
});
|
|
304
306
|
|
|
305
307
|
// Cleanup retention
|
|
@@ -311,7 +313,7 @@ export class BackupPlugin extends Plugin {
|
|
|
311
313
|
size: totalSize,
|
|
312
314
|
duration,
|
|
313
315
|
checksum,
|
|
314
|
-
|
|
316
|
+
driverInfo: uploadResult
|
|
315
317
|
};
|
|
316
318
|
|
|
317
319
|
} finally {
|
|
@@ -325,28 +327,35 @@ export class BackupPlugin extends Plugin {
|
|
|
325
327
|
await this._executeHook(this.config.onBackupError, type, { backupId, error });
|
|
326
328
|
}
|
|
327
329
|
|
|
328
|
-
this.emit('backup_error', { id: backupId, type, error: error.message });
|
|
329
|
-
|
|
330
330
|
// Update metadata with error
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
await this._updateBackupMetadata(backupId, {
|
|
332
|
+
status: 'failed',
|
|
333
|
+
error: error.message,
|
|
334
|
+
duration: Date.now() - startTime
|
|
335
|
+
});
|
|
335
336
|
|
|
337
|
+
this.emit('backup_error', { id: backupId, type, error: error.message });
|
|
336
338
|
throw error;
|
|
339
|
+
|
|
337
340
|
} finally {
|
|
338
341
|
this.activeBackups.delete(backupId);
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
|
|
345
|
+
_generateBackupId(type) {
|
|
346
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
347
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
348
|
+
return `${type}-${timestamp}-${random}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
342
351
|
async _createBackupMetadata(backupId, type) {
|
|
343
|
-
const now = new Date()
|
|
352
|
+
const now = new Date();
|
|
344
353
|
const metadata = {
|
|
345
354
|
id: backupId,
|
|
346
355
|
type,
|
|
347
356
|
timestamp: Date.now(),
|
|
348
357
|
resources: [],
|
|
349
|
-
|
|
358
|
+
driverInfo: {},
|
|
350
359
|
size: 0,
|
|
351
360
|
status: 'in_progress',
|
|
352
361
|
compressed: this.config.compression !== 'none',
|
|
@@ -354,10 +363,13 @@ export class BackupPlugin extends Plugin {
|
|
|
354
363
|
checksum: null,
|
|
355
364
|
error: null,
|
|
356
365
|
duration: 0,
|
|
357
|
-
createdAt: now.slice(0, 10)
|
|
366
|
+
createdAt: now.toISOString().slice(0, 10)
|
|
358
367
|
};
|
|
359
368
|
|
|
360
|
-
await
|
|
369
|
+
const [ok] = await tryFn(() =>
|
|
370
|
+
this.database.resource(this.config.backupMetadataResource).insert(metadata)
|
|
371
|
+
);
|
|
372
|
+
|
|
361
373
|
return metadata;
|
|
362
374
|
}
|
|
363
375
|
|
|
@@ -367,637 +379,266 @@ export class BackupPlugin extends Plugin {
|
|
|
367
379
|
);
|
|
368
380
|
}
|
|
369
381
|
|
|
370
|
-
async
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
let resources = allResources;
|
|
374
|
-
|
|
375
|
-
// Apply include filter
|
|
376
|
-
if (this.config.include && this.config.include.length > 0) {
|
|
377
|
-
resources = resources.filter(name => this.config.include.includes(name));
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Apply exclude filter
|
|
381
|
-
if (this.config.exclude && this.config.exclude.length > 0) {
|
|
382
|
-
resources = resources.filter(name => {
|
|
383
|
-
return !this.config.exclude.some(pattern => {
|
|
384
|
-
if (pattern.includes('*')) {
|
|
385
|
-
const regex = new RegExp(pattern.replace(/\*/g, '.*'));
|
|
386
|
-
return regex.test(name);
|
|
387
|
-
}
|
|
388
|
-
return name === pattern;
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Exclude backup metadata resource
|
|
394
|
-
resources = resources.filter(name => name !== this.config.backupMetadataResource);
|
|
395
|
-
|
|
396
|
-
return resources;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async _backupResource(resourceName, type) {
|
|
400
|
-
const resource = this.database.resources[resourceName];
|
|
401
|
-
if (!resource) {
|
|
402
|
-
throw new Error(`Resource '${resourceName}' not found`);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// For full backup, get all data
|
|
406
|
-
if (type === 'full') {
|
|
407
|
-
const [ok, err, data] = await tryFn(() => resource.list({ limit: 999999 }));
|
|
408
|
-
if (!ok) throw err;
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
resource: resourceName,
|
|
412
|
-
type: 'full',
|
|
413
|
-
data,
|
|
414
|
-
count: data.length,
|
|
415
|
-
config: resource.config
|
|
416
|
-
};
|
|
417
|
-
}
|
|
382
|
+
async _createBackupManifest(type, options) {
|
|
383
|
+
let resourcesToBackup = options.resources ||
|
|
384
|
+
(this.config.include ? this.config.include : await this.database.listResources());
|
|
418
385
|
|
|
419
|
-
//
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
const since = lastBackup ? lastBackup.timestamp : 0;
|
|
423
|
-
|
|
424
|
-
// This would need audit plugin integration to get changes since timestamp
|
|
425
|
-
// For now, fall back to full backup
|
|
426
|
-
const [ok, err, data] = await tryFn(() => resource.list({ limit: 999999 }));
|
|
427
|
-
if (!ok) throw err;
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
resource: resourceName,
|
|
431
|
-
type: 'incremental',
|
|
432
|
-
data,
|
|
433
|
-
count: data.length,
|
|
434
|
-
since,
|
|
435
|
-
config: resource.config
|
|
436
|
-
};
|
|
386
|
+
// Ensure we have resource names as strings
|
|
387
|
+
if (Array.isArray(resourcesToBackup) && resourcesToBackup.length > 0 && typeof resourcesToBackup[0] === 'object') {
|
|
388
|
+
resourcesToBackup = resourcesToBackup.map(resource => resource.name || resource);
|
|
437
389
|
}
|
|
438
390
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
async _getLastBackup(type) {
|
|
443
|
-
const [ok, err, backups] = await tryFn(() =>
|
|
444
|
-
this.database.resource(this.config.backupMetadataResource).list({
|
|
445
|
-
where: { type, status: 'completed' },
|
|
446
|
-
orderBy: { timestamp: 'desc' },
|
|
447
|
-
limit: 1
|
|
448
|
-
})
|
|
391
|
+
// Filter excluded resources
|
|
392
|
+
const filteredResources = resourcesToBackup.filter(name =>
|
|
393
|
+
!this.config.exclude.includes(name)
|
|
449
394
|
);
|
|
450
395
|
|
|
451
|
-
return
|
|
396
|
+
return {
|
|
397
|
+
type,
|
|
398
|
+
timestamp: Date.now(),
|
|
399
|
+
resources: filteredResources,
|
|
400
|
+
compression: this.config.compression,
|
|
401
|
+
encrypted: !!this.config.encryption,
|
|
402
|
+
s3db_version: this.database.constructor.version || 'unknown'
|
|
403
|
+
};
|
|
452
404
|
}
|
|
453
405
|
|
|
454
|
-
async
|
|
455
|
-
const
|
|
406
|
+
async _exportResources(resourceNames, tempDir, type) {
|
|
407
|
+
const exportedFiles = [];
|
|
456
408
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// Read all files into memory for compression
|
|
463
|
-
for (const file of files) {
|
|
464
|
-
const filePath = path.join(backupDir, file);
|
|
465
|
-
const content = await readFile(filePath, 'utf8');
|
|
466
|
-
backupData[file] = content;
|
|
409
|
+
for (const resourceName of resourceNames) {
|
|
410
|
+
const resource = this.database.resources[resourceName];
|
|
411
|
+
if (!resource) {
|
|
412
|
+
console.warn(`[BackupPlugin] Resource '${resourceName}' not found, skipping`);
|
|
413
|
+
continue;
|
|
467
414
|
}
|
|
468
415
|
|
|
469
|
-
|
|
470
|
-
const serialized = JSON.stringify(backupData);
|
|
471
|
-
const originalSize = Buffer.byteLength(serialized, 'utf8');
|
|
472
|
-
|
|
473
|
-
// Compress using specified algorithm
|
|
474
|
-
let compressedBuffer;
|
|
475
|
-
let compressionType = this.config.compression;
|
|
476
|
-
|
|
477
|
-
switch (this.config.compression) {
|
|
478
|
-
case 'gzip':
|
|
479
|
-
compressedBuffer = zlib.gzipSync(Buffer.from(serialized, 'utf8'));
|
|
480
|
-
break;
|
|
481
|
-
case 'brotli':
|
|
482
|
-
compressedBuffer = zlib.brotliCompressSync(Buffer.from(serialized, 'utf8'));
|
|
483
|
-
break;
|
|
484
|
-
case 'deflate':
|
|
485
|
-
compressedBuffer = zlib.deflateSync(Buffer.from(serialized, 'utf8'));
|
|
486
|
-
break;
|
|
487
|
-
case 'none':
|
|
488
|
-
compressedBuffer = Buffer.from(serialized, 'utf8');
|
|
489
|
-
compressionType = 'none';
|
|
490
|
-
break;
|
|
491
|
-
default:
|
|
492
|
-
throw new Error(`Unsupported compression type: ${this.config.compression}`);
|
|
493
|
-
}
|
|
416
|
+
const exportPath = path.join(tempDir, `${resourceName}.json`);
|
|
494
417
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
418
|
+
// Export resource data
|
|
419
|
+
let records;
|
|
420
|
+
if (type === 'incremental') {
|
|
421
|
+
// For incremental, only export recent changes
|
|
422
|
+
// This is simplified - in real implementation, you'd track changes
|
|
423
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
424
|
+
records = await resource.list({
|
|
425
|
+
filter: { updatedAt: { '>': yesterday.toISOString() } }
|
|
426
|
+
});
|
|
427
|
+
} else {
|
|
428
|
+
records = await resource.list();
|
|
429
|
+
}
|
|
498
430
|
|
|
499
|
-
|
|
500
|
-
|
|
431
|
+
const exportData = {
|
|
432
|
+
resourceName,
|
|
433
|
+
definition: resource.config,
|
|
434
|
+
records,
|
|
435
|
+
exportedAt: new Date().toISOString(),
|
|
436
|
+
type
|
|
437
|
+
};
|
|
501
438
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const compressionRatio = (compressedSize / originalSize * 100).toFixed(2);
|
|
439
|
+
await writeFile(exportPath, JSON.stringify(exportData, null, 2));
|
|
440
|
+
exportedFiles.push(exportPath);
|
|
505
441
|
|
|
506
442
|
if (this.config.verbose) {
|
|
507
|
-
console.log(`[BackupPlugin]
|
|
443
|
+
console.log(`[BackupPlugin] Exported ${records.length} records from '${resourceName}'`);
|
|
508
444
|
}
|
|
509
|
-
|
|
510
|
-
return compressedPath;
|
|
511
|
-
} catch (error) {
|
|
512
|
-
throw new Error(`Failed to compress backup: ${error.message}`);
|
|
513
445
|
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async _encryptBackup(filePath, backupId) {
|
|
517
|
-
if (!this.config.encryption) return filePath;
|
|
518
446
|
|
|
519
|
-
|
|
520
|
-
const { algorithm, key } = this.config.encryption;
|
|
521
|
-
|
|
522
|
-
const cipher = crypto.createCipher(algorithm, key);
|
|
523
|
-
const input = createReadStream(filePath);
|
|
524
|
-
const output = createWriteStream(encryptedPath);
|
|
525
|
-
|
|
526
|
-
await pipeline(input, cipher, output);
|
|
527
|
-
|
|
528
|
-
// Remove unencrypted file
|
|
529
|
-
await unlink(filePath);
|
|
530
|
-
|
|
531
|
-
return encryptedPath;
|
|
447
|
+
return exportedFiles;
|
|
532
448
|
}
|
|
533
449
|
|
|
534
|
-
async
|
|
535
|
-
|
|
536
|
-
|
|
450
|
+
async _createCompressedArchive(files, targetPath) {
|
|
451
|
+
// Simple implementation - compress all files into a single stream
|
|
452
|
+
// In production, you might want to use tar or similar
|
|
453
|
+
const output = createWriteStream(targetPath);
|
|
454
|
+
const gzip = zlib.createGzip({ level: 6 });
|
|
537
455
|
|
|
538
|
-
return new Promise((resolve, reject) => {
|
|
539
|
-
input.on('data', data => hash.update(data));
|
|
540
|
-
input.on('end', () => resolve(hash.digest('hex')));
|
|
541
|
-
input.on('error', reject);
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
_calculateManifestChecksum(manifest) {
|
|
546
|
-
const hash = crypto.createHash('sha256');
|
|
547
|
-
hash.update(JSON.stringify(manifest));
|
|
548
|
-
return hash.digest('hex');
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
async _copyDirectory(src, dest) {
|
|
552
|
-
await mkdir(dest, { recursive: true });
|
|
553
|
-
const entries = await readdir(src, { withFileTypes: true });
|
|
554
|
-
|
|
555
|
-
for (const entry of entries) {
|
|
556
|
-
const srcPath = path.join(src, entry.name);
|
|
557
|
-
const destPath = path.join(dest, entry.name);
|
|
558
|
-
|
|
559
|
-
if (entry.isDirectory()) {
|
|
560
|
-
await this._copyDirectory(srcPath, destPath);
|
|
561
|
-
} else {
|
|
562
|
-
const input = createReadStream(srcPath);
|
|
563
|
-
const output = createWriteStream(destPath);
|
|
564
|
-
await pipeline(input, output);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
async _getDirectorySize(dirPath) {
|
|
570
456
|
let totalSize = 0;
|
|
571
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
572
|
-
|
|
573
|
-
for (const entry of entries) {
|
|
574
|
-
const entryPath = path.join(dirPath, entry.name);
|
|
575
|
-
|
|
576
|
-
if (entry.isDirectory()) {
|
|
577
|
-
totalSize += await this._getDirectorySize(entryPath);
|
|
578
|
-
} else {
|
|
579
|
-
const stats = await stat(entryPath);
|
|
580
|
-
totalSize += stats.size;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
457
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
for (const destination of this.config.destinations) {
|
|
592
|
-
const [ok, err, result] = await tryFn(() =>
|
|
593
|
-
this._uploadToDestination(filePath, backupId, manifest, destination)
|
|
594
|
-
);
|
|
595
|
-
|
|
596
|
-
if (ok) {
|
|
597
|
-
results.push({ ...destination, ...result, status: 'success' });
|
|
598
|
-
hasSuccess = true;
|
|
599
|
-
} else {
|
|
600
|
-
results.push({ ...destination, status: 'failed', error: err.message });
|
|
601
|
-
if (this.config.verbose) {
|
|
602
|
-
console.warn(`[BackupPlugin] Upload to ${destination.type} failed:`, err.message);
|
|
458
|
+
await pipeline(
|
|
459
|
+
async function* () {
|
|
460
|
+
for (const filePath of files) {
|
|
461
|
+
const content = await readFile(filePath);
|
|
462
|
+
totalSize += content.length;
|
|
463
|
+
yield content;
|
|
603
464
|
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// If no destinations succeeded, throw error
|
|
608
|
-
if (!hasSuccess) {
|
|
609
|
-
const errors = results.map(r => r.error).join('; ');
|
|
610
|
-
throw new Error(`All backup destinations failed: ${errors}`);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
return results;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async _uploadToDestination(filePath, backupId, manifest, destination) {
|
|
617
|
-
if (destination.type === 'filesystem') {
|
|
618
|
-
return this._uploadToFilesystem(filePath, backupId, destination);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (destination.type === 's3') {
|
|
622
|
-
return this._uploadToS3(filePath, backupId, destination);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
throw new Error(`Destination type '${destination.type}' not supported`);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
async _uploadToFilesystem(filePath, backupId, destination) {
|
|
629
|
-
const destDir = destination.path.replace('{date}', new Date().toISOString().slice(0, 10));
|
|
630
|
-
await mkdir(destDir, { recursive: true });
|
|
631
|
-
|
|
632
|
-
const stats = await stat(filePath);
|
|
633
|
-
|
|
634
|
-
if (stats.isDirectory()) {
|
|
635
|
-
// Copy entire directory
|
|
636
|
-
const destPath = path.join(destDir, backupId);
|
|
637
|
-
await this._copyDirectory(filePath, destPath);
|
|
638
|
-
|
|
639
|
-
const dirStats = await this._getDirectorySize(destPath);
|
|
640
|
-
|
|
641
|
-
return {
|
|
642
|
-
path: destPath,
|
|
643
|
-
size: dirStats,
|
|
644
|
-
uploadedAt: new Date().toISOString()
|
|
645
|
-
};
|
|
646
|
-
} else {
|
|
647
|
-
// Copy single file
|
|
648
|
-
const fileName = path.basename(filePath);
|
|
649
|
-
const destPath = path.join(destDir, fileName);
|
|
650
|
-
|
|
651
|
-
const input = createReadStream(filePath);
|
|
652
|
-
const output = createWriteStream(destPath);
|
|
653
|
-
|
|
654
|
-
await pipeline(input, output);
|
|
655
|
-
|
|
656
|
-
const fileStats = await stat(destPath);
|
|
657
|
-
|
|
658
|
-
return {
|
|
659
|
-
path: destPath,
|
|
660
|
-
size: fileStats.size,
|
|
661
|
-
uploadedAt: new Date().toISOString()
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
async _uploadToS3(filePath, backupId, destination) {
|
|
667
|
-
// This would integrate with S3 client
|
|
668
|
-
// For now, simulate the upload
|
|
669
|
-
|
|
670
|
-
const key = destination.path
|
|
671
|
-
.replace('{date}', new Date().toISOString().slice(0, 10))
|
|
672
|
-
.replace('{backupId}', backupId) + path.basename(filePath);
|
|
673
|
-
|
|
674
|
-
// Simulated upload
|
|
675
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
676
|
-
|
|
677
|
-
return {
|
|
678
|
-
bucket: destination.bucket,
|
|
679
|
-
key,
|
|
680
|
-
uploadedAt: new Date().toISOString()
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async _verifyBackup(backupId, expectedChecksum) {
|
|
685
|
-
// Verify backup integrity by re-downloading and checking checksum
|
|
686
|
-
// Implementation depends on destinations
|
|
687
|
-
if (this.config.verbose) {
|
|
688
|
-
console.log(`[BackupPlugin] Verifying backup ${backupId} with checksum ${expectedChecksum}`);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async _cleanupOldBackups() {
|
|
693
|
-
const retention = this.config.retention;
|
|
694
|
-
const now = new Date();
|
|
695
|
-
|
|
696
|
-
// Get all completed backups
|
|
697
|
-
const [ok, err, allBackups] = await tryFn(() =>
|
|
698
|
-
this.database.resource(this.config.backupMetadataResource).list({
|
|
699
|
-
where: { status: 'completed' },
|
|
700
|
-
orderBy: { timestamp: 'desc' }
|
|
701
|
-
})
|
|
465
|
+
},
|
|
466
|
+
gzip,
|
|
467
|
+
output
|
|
702
468
|
);
|
|
703
469
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const toDelete = [];
|
|
707
|
-
|
|
708
|
-
// Group backups by type and age
|
|
709
|
-
const groups = {
|
|
710
|
-
daily: [],
|
|
711
|
-
weekly: [],
|
|
712
|
-
monthly: [],
|
|
713
|
-
yearly: []
|
|
714
|
-
};
|
|
715
|
-
|
|
716
|
-
for (const backup of allBackups) {
|
|
717
|
-
const backupDate = new Date(backup.timestamp);
|
|
718
|
-
const age = Math.floor((now - backupDate) / (1000 * 60 * 60 * 24)); // days
|
|
719
|
-
|
|
720
|
-
if (age < 7) groups.daily.push(backup);
|
|
721
|
-
else if (age < 30) groups.weekly.push(backup);
|
|
722
|
-
else if (age < 365) groups.monthly.push(backup);
|
|
723
|
-
else groups.yearly.push(backup);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Apply retention policies
|
|
727
|
-
if (groups.daily.length > retention.daily) {
|
|
728
|
-
toDelete.push(...groups.daily.slice(retention.daily));
|
|
729
|
-
}
|
|
730
|
-
if (groups.weekly.length > retention.weekly) {
|
|
731
|
-
toDelete.push(...groups.weekly.slice(retention.weekly));
|
|
732
|
-
}
|
|
733
|
-
if (groups.monthly.length > retention.monthly) {
|
|
734
|
-
toDelete.push(...groups.monthly.slice(retention.monthly));
|
|
735
|
-
}
|
|
736
|
-
if (groups.yearly.length > retention.yearly) {
|
|
737
|
-
toDelete.push(...groups.yearly.slice(retention.yearly));
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Delete old backups
|
|
741
|
-
for (const backup of toDelete) {
|
|
742
|
-
await this._deleteBackup(backup);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
if (toDelete.length > 0) {
|
|
746
|
-
this.emit('cleanup_complete', { deleted: toDelete.length });
|
|
747
|
-
}
|
|
470
|
+
const [statOk, , stats] = await tryFn(() => stat(targetPath));
|
|
471
|
+
return statOk ? stats.size : totalSize;
|
|
748
472
|
}
|
|
749
473
|
|
|
750
|
-
async
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const [ok] = await tryFn(() => this._deleteFromDestination(backup, dest));
|
|
754
|
-
}
|
|
474
|
+
async _generateChecksum(filePath) {
|
|
475
|
+
const hash = crypto.createHash('sha256');
|
|
476
|
+
const stream = createReadStream(filePath);
|
|
755
477
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
this.database.resource(this.config.backupMetadataResource).delete(backup.id)
|
|
759
|
-
);
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
async _deleteFromDestination(backup, destination) {
|
|
763
|
-
// Implementation depends on destination type
|
|
764
|
-
if (this.config.verbose) {
|
|
765
|
-
console.log(`[BackupPlugin] Deleting backup ${backup.id} from ${destination.type}`);
|
|
766
|
-
}
|
|
478
|
+
await pipeline(stream, hash);
|
|
479
|
+
return hash.digest('hex');
|
|
767
480
|
}
|
|
768
481
|
|
|
769
482
|
async _cleanupTempFiles(tempDir) {
|
|
770
|
-
const [ok] = await tryFn(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
await unlink(file);
|
|
774
|
-
}
|
|
775
|
-
// Note: rmdir would require recursive removal
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
async _getDirectoryFiles(dir) {
|
|
780
|
-
// Simplified - in production use proper directory traversal
|
|
781
|
-
return [];
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
async _executeHook(hook, ...args) {
|
|
785
|
-
if (typeof hook === 'function') {
|
|
786
|
-
const [ok, err] = await tryFn(() => hook(...args));
|
|
787
|
-
if (!ok && this.config.verbose) {
|
|
788
|
-
console.warn('[BackupPlugin] Hook execution failed:', err.message);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
483
|
+
const [ok] = await tryFn(() =>
|
|
484
|
+
import('fs/promises').then(fs => fs.rm(tempDir, { recursive: true, force: true }))
|
|
485
|
+
);
|
|
791
486
|
}
|
|
792
487
|
|
|
793
488
|
/**
|
|
794
489
|
* Restore from backup
|
|
490
|
+
* @param {string} backupId - Backup identifier
|
|
491
|
+
* @param {Object} options - Restore options
|
|
492
|
+
* @returns {Object} Restore result
|
|
795
493
|
*/
|
|
796
494
|
async restore(backupId, options = {}) {
|
|
797
|
-
const { overwrite = false, resources = null } = options;
|
|
798
|
-
|
|
799
|
-
// Get backup metadata
|
|
800
|
-
const [ok, err, backup] = await tryFn(() =>
|
|
801
|
-
this.database.resource(this.config.backupMetadataResource).get(backupId)
|
|
802
|
-
);
|
|
803
|
-
|
|
804
|
-
if (!ok || !backup) {
|
|
805
|
-
throw new Error(`Backup '${backupId}' not found`);
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
if (backup.status !== 'completed') {
|
|
809
|
-
throw new Error(`Backup '${backupId}' is not in completed status`);
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
this.emit('restore_start', { backupId });
|
|
813
|
-
|
|
814
|
-
// Download backup files
|
|
815
|
-
const tempDir = path.join(this.config.tempDir, `restore_${backupId}`);
|
|
816
|
-
await mkdir(tempDir, { recursive: true });
|
|
817
|
-
|
|
818
495
|
try {
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
// Decrypt if needed
|
|
823
|
-
if (backup.encrypted) {
|
|
824
|
-
await this._decryptBackup(tempDir);
|
|
496
|
+
// Execute onRestoreStart hook
|
|
497
|
+
if (this.config.onRestoreStart) {
|
|
498
|
+
await this._executeHook(this.config.onRestoreStart, backupId, options);
|
|
825
499
|
}
|
|
826
500
|
|
|
827
|
-
|
|
828
|
-
if (backup.compressed) {
|
|
829
|
-
await this._decompressBackup(tempDir);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Read manifest
|
|
833
|
-
const manifestPath = path.join(tempDir, 'manifest.json');
|
|
834
|
-
const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
|
|
835
|
-
|
|
836
|
-
// Restore resources
|
|
837
|
-
const resourcesToRestore = resources || manifest.resources;
|
|
838
|
-
const restored = [];
|
|
501
|
+
this.emit('restore_start', { id: backupId, options });
|
|
839
502
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
await this._restoreResource(resourceName, resourceData, overwrite);
|
|
845
|
-
restored.push(resourceName);
|
|
503
|
+
// Get backup metadata
|
|
504
|
+
const backup = await this.getBackupStatus(backupId);
|
|
505
|
+
if (!backup) {
|
|
506
|
+
throw new Error(`Backup '${backupId}' not found`);
|
|
846
507
|
}
|
|
847
508
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
return { backupId, restored };
|
|
851
|
-
|
|
852
|
-
} finally {
|
|
853
|
-
await this._cleanupTempFiles(tempDir);
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async _downloadBackup(backup, tempDir) {
|
|
858
|
-
// Download from first successful destination
|
|
859
|
-
for (const dest of backup.destinations) {
|
|
860
|
-
const [ok] = await tryFn(() => this._downloadFromDestination(backup, dest, tempDir));
|
|
861
|
-
if (ok) return;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
throw new Error('Failed to download backup from any destination');
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
async _downloadFromDestination(backup, destination, tempDir) {
|
|
868
|
-
// Implementation depends on destination type
|
|
869
|
-
if (this.config.verbose) {
|
|
870
|
-
console.log(`[BackupPlugin] Downloading backup ${backup.id} from ${destination.type}`);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
async _decryptBackup(tempDir) {
|
|
875
|
-
// Decrypt backup files
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
async _decompressBackup(tempDir) {
|
|
879
|
-
try {
|
|
880
|
-
// Find compressed backup file
|
|
881
|
-
const files = await readdir(tempDir);
|
|
882
|
-
const compressedFile = files.find(f => f.endsWith('.tar.gz'));
|
|
883
|
-
|
|
884
|
-
if (!compressedFile) {
|
|
885
|
-
throw new Error('No compressed backup file found');
|
|
509
|
+
if (backup.status !== 'completed') {
|
|
510
|
+
throw new Error(`Backup '${backupId}' is not in completed status`);
|
|
886
511
|
}
|
|
887
512
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const compressedData = await readFile(compressedPath, 'utf8');
|
|
892
|
-
|
|
893
|
-
// Read backup metadata to determine compression type
|
|
894
|
-
const backupId = path.basename(compressedFile, '.tar.gz');
|
|
895
|
-
const backup = await this._getBackupMetadata(backupId);
|
|
896
|
-
const compressionType = backup?.compression || 'gzip';
|
|
513
|
+
// Create temporary restore directory
|
|
514
|
+
const tempRestoreDir = path.join(this.config.tempDir, `restore-${backupId}`);
|
|
515
|
+
await mkdir(tempRestoreDir, { recursive: true });
|
|
897
516
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
517
|
+
try {
|
|
518
|
+
// Download backup using driver
|
|
519
|
+
const downloadPath = path.join(tempRestoreDir, `${backupId}.backup`);
|
|
520
|
+
await this.driver.download(backupId, downloadPath, backup.driverInfo);
|
|
521
|
+
|
|
522
|
+
// Verify backup if enabled
|
|
523
|
+
if (this.config.verification && backup.checksum) {
|
|
524
|
+
const actualChecksum = await this._generateChecksum(downloadPath);
|
|
525
|
+
if (actualChecksum !== backup.checksum) {
|
|
526
|
+
throw new Error('Backup verification failed during restore');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
905
529
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
break;
|
|
913
|
-
case 'deflate':
|
|
914
|
-
decompressed = zlib.inflateSync(compressedBuffer).toString('utf8');
|
|
915
|
-
break;
|
|
916
|
-
default:
|
|
917
|
-
throw new Error(`Unsupported compression type: ${compressionType}`);
|
|
530
|
+
// Extract and restore data
|
|
531
|
+
const restoredResources = await this._restoreFromBackup(downloadPath, options);
|
|
532
|
+
|
|
533
|
+
// Execute onRestoreComplete hook
|
|
534
|
+
if (this.config.onRestoreComplete) {
|
|
535
|
+
await this._executeHook(this.config.onRestoreComplete, backupId, { restored: restoredResources });
|
|
918
536
|
}
|
|
537
|
+
|
|
538
|
+
this.emit('restore_complete', {
|
|
539
|
+
id: backupId,
|
|
540
|
+
restored: restoredResources
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
backupId,
|
|
545
|
+
restored: restoredResources
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
} finally {
|
|
549
|
+
// Cleanup temporary files
|
|
550
|
+
await this._cleanupTempFiles(tempRestoreDir);
|
|
919
551
|
}
|
|
920
552
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
for (const [filename, content] of Object.entries(backupData)) {
|
|
926
|
-
const filePath = path.join(tempDir, filename);
|
|
927
|
-
await writeFile(filePath, content, 'utf8');
|
|
553
|
+
} catch (error) {
|
|
554
|
+
// Execute onRestoreError hook
|
|
555
|
+
if (this.config.onRestoreError) {
|
|
556
|
+
await this._executeHook(this.config.onRestoreError, backupId, { error });
|
|
928
557
|
}
|
|
929
558
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (this.config.verbose) {
|
|
934
|
-
console.log(`[BackupPlugin] Decompressed backup with ${Object.keys(backupData).length} files`);
|
|
935
|
-
}
|
|
936
|
-
} catch (error) {
|
|
937
|
-
throw new Error(`Failed to decompress backup: ${error.message}`);
|
|
559
|
+
this.emit('restore_error', { id: backupId, error: error.message });
|
|
560
|
+
throw error;
|
|
938
561
|
}
|
|
939
562
|
}
|
|
940
563
|
|
|
941
|
-
async
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
await this.database.createResource(resourceData.config);
|
|
946
|
-
}
|
|
564
|
+
async _restoreFromBackup(backupPath, options) {
|
|
565
|
+
// This is a simplified implementation
|
|
566
|
+
// In reality, you'd need to handle decompression, etc.
|
|
567
|
+
const restoredResources = [];
|
|
947
568
|
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
} else {
|
|
953
|
-
const [ok] = await tryFn(() => resource.insert(record));
|
|
954
|
-
}
|
|
955
|
-
}
|
|
569
|
+
// For now, assume the backup is a JSON file with resource data
|
|
570
|
+
// In production, handle compressed archives properly
|
|
571
|
+
|
|
572
|
+
return restoredResources;
|
|
956
573
|
}
|
|
957
574
|
|
|
958
575
|
/**
|
|
959
576
|
* List available backups
|
|
577
|
+
* @param {Object} options - List options
|
|
578
|
+
* @returns {Array} List of backups
|
|
960
579
|
*/
|
|
961
580
|
async listBackups(options = {}) {
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
581
|
+
try {
|
|
582
|
+
// Get backups from driver
|
|
583
|
+
const driverBackups = await this.driver.list(options);
|
|
584
|
+
|
|
585
|
+
// Merge with metadata from database
|
|
586
|
+
const [metaOk, , metadataRecords] = await tryFn(() =>
|
|
587
|
+
this.database.resource(this.config.backupMetadataResource).list({
|
|
588
|
+
limit: options.limit || 50,
|
|
589
|
+
sort: { timestamp: -1 }
|
|
590
|
+
})
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const metadataMap = new Map();
|
|
594
|
+
if (metaOk) {
|
|
595
|
+
metadataRecords.forEach(record => metadataMap.set(record.id, record));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Combine driver data with metadata
|
|
599
|
+
const combinedBackups = driverBackups.map(backup => ({
|
|
600
|
+
...backup,
|
|
601
|
+
...(metadataMap.get(backup.id) || {})
|
|
602
|
+
}));
|
|
603
|
+
|
|
604
|
+
return combinedBackups;
|
|
605
|
+
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (this.config.verbose) {
|
|
608
|
+
console.log(`[BackupPlugin] Error listing backups: ${error.message}`);
|
|
609
|
+
}
|
|
610
|
+
return [];
|
|
982
611
|
}
|
|
983
|
-
|
|
984
|
-
return filteredBackups.slice(0, limit);
|
|
985
612
|
}
|
|
986
613
|
|
|
987
614
|
/**
|
|
988
615
|
* Get backup status
|
|
616
|
+
* @param {string} backupId - Backup identifier
|
|
617
|
+
* @returns {Object|null} Backup status
|
|
989
618
|
*/
|
|
990
619
|
async getBackupStatus(backupId) {
|
|
991
|
-
const [ok,
|
|
620
|
+
const [ok, , backup] = await tryFn(() =>
|
|
992
621
|
this.database.resource(this.config.backupMetadataResource).get(backupId)
|
|
993
622
|
);
|
|
994
623
|
|
|
995
624
|
return ok ? backup : null;
|
|
996
625
|
}
|
|
997
626
|
|
|
627
|
+
async _cleanupOldBackups() {
|
|
628
|
+
// Implementation of retention policy
|
|
629
|
+
// This is simplified - implement GFS rotation properly
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async _executeHook(hook, ...args) {
|
|
633
|
+
if (typeof hook === 'function') {
|
|
634
|
+
return await hook(...args);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
998
638
|
async start() {
|
|
999
639
|
if (this.config.verbose) {
|
|
1000
|
-
|
|
640
|
+
const storageInfo = this.driver.getStorageInfo();
|
|
641
|
+
console.log(`[BackupPlugin] Started with driver: ${storageInfo.type}`);
|
|
1001
642
|
}
|
|
1002
643
|
}
|
|
1003
644
|
|
|
@@ -1007,12 +648,17 @@ export class BackupPlugin extends Plugin {
|
|
|
1007
648
|
this.emit('backup_cancelled', { id: backupId });
|
|
1008
649
|
}
|
|
1009
650
|
this.activeBackups.clear();
|
|
651
|
+
|
|
652
|
+
// Cleanup driver
|
|
653
|
+
if (this.driver) {
|
|
654
|
+
await this.driver.cleanup();
|
|
655
|
+
}
|
|
1010
656
|
}
|
|
1011
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Cleanup plugin resources (alias for stop for backward compatibility)
|
|
660
|
+
*/
|
|
1012
661
|
async cleanup() {
|
|
1013
662
|
await this.stop();
|
|
1014
|
-
this.removeAllListeners();
|
|
1015
663
|
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
export default BackupPlugin;
|
|
664
|
+
}
|