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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.2",
|
|
4
4
|
"description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
|
|
5
5
|
"main": "dist/s3db.cjs.js",
|
|
6
6
|
"module": "dist/s3db.es.js",
|
|
@@ -103,23 +103,23 @@
|
|
|
103
103
|
"@rollup/plugin-terser": "^0.4.4",
|
|
104
104
|
"@types/node": "24.3.0",
|
|
105
105
|
"babel-loader": "^10.0.0",
|
|
106
|
-
"chalk": "^5.
|
|
106
|
+
"chalk": "^5.6.0",
|
|
107
107
|
"cli-table3": "^0.6.5",
|
|
108
108
|
"commander": "^14.0.0",
|
|
109
109
|
"esbuild": "^0.25.9",
|
|
110
|
-
"inquirer": "^12.9.
|
|
110
|
+
"inquirer": "^12.9.3",
|
|
111
111
|
"jest": "^30.0.5",
|
|
112
112
|
"node-loader": "^2.1.0",
|
|
113
113
|
"ora": "^8.2.0",
|
|
114
114
|
"pkg": "^5.8.1",
|
|
115
|
-
"rollup": "^4.46.
|
|
115
|
+
"rollup": "^4.46.4",
|
|
116
116
|
"rollup-plugin-copy": "^3.5.0",
|
|
117
117
|
"rollup-plugin-esbuild": "^6.2.1",
|
|
118
118
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
|
119
119
|
"rollup-plugin-shebang-bin": "^0.1.0",
|
|
120
120
|
"rollup-plugin-terser": "^7.0.2",
|
|
121
121
|
"typescript": "5.9.2",
|
|
122
|
-
"webpack": "^5.101.
|
|
122
|
+
"webpack": "^5.101.3",
|
|
123
123
|
"webpack-cli": "^6.0.1"
|
|
124
124
|
},
|
|
125
125
|
"funding": [
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import EventEmitter from 'events';
|
|
2
|
+
|
|
3
|
+
class AsyncEventEmitter extends EventEmitter {
|
|
4
|
+
constructor() {
|
|
5
|
+
super();
|
|
6
|
+
this._asyncMode = true;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
emit(event, ...args) {
|
|
10
|
+
if (!this._asyncMode) {
|
|
11
|
+
return super.emit(event, ...args);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const listeners = this.listeners(event);
|
|
15
|
+
|
|
16
|
+
if (listeners.length === 0) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setImmediate(async () => {
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
try {
|
|
23
|
+
await listener(...args);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (event !== 'error') {
|
|
26
|
+
this.emit('error', error);
|
|
27
|
+
} else {
|
|
28
|
+
console.error('Error in error handler:', error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
emitSync(event, ...args) {
|
|
38
|
+
return super.emit(event, ...args);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setAsyncMode(enabled) {
|
|
42
|
+
this._asyncMode = enabled;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default AsyncEventEmitter;
|
package/src/database.class.js
CHANGED
|
@@ -74,6 +74,9 @@ export class Database extends EventEmitter {
|
|
|
74
74
|
parallelism: this.parallelism,
|
|
75
75
|
connectionString: connectionString,
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
// Store connection string for CLI access
|
|
79
|
+
this.connectionString = connectionString;
|
|
77
80
|
|
|
78
81
|
this.bucket = this.client.bucket;
|
|
79
82
|
this.keyPrefix = this.client.keyPrefix;
|
|
@@ -193,6 +196,7 @@ export class Database extends EventEmitter {
|
|
|
193
196
|
paranoid: versionData.paranoid !== undefined ? versionData.paranoid : true,
|
|
194
197
|
allNestedObjectsOptional: versionData.allNestedObjectsOptional !== undefined ? versionData.allNestedObjectsOptional : true,
|
|
195
198
|
autoDecrypt: versionData.autoDecrypt !== undefined ? versionData.autoDecrypt : true,
|
|
199
|
+
asyncEvents: versionData.asyncEvents !== undefined ? versionData.asyncEvents : true,
|
|
196
200
|
hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : (versionData.hooks || {}),
|
|
197
201
|
versioningEnabled: this.versioningEnabled,
|
|
198
202
|
map: versionData.map,
|
|
@@ -483,6 +487,7 @@ export class Database extends EventEmitter {
|
|
|
483
487
|
allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
|
|
484
488
|
autoDecrypt: resource.config.autoDecrypt,
|
|
485
489
|
cache: resource.config.cache,
|
|
490
|
+
asyncEvents: resource.config.asyncEvents,
|
|
486
491
|
hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
|
|
487
492
|
idSize: resource.idSize,
|
|
488
493
|
idGenerator: resource.idGeneratorType,
|
|
@@ -886,6 +891,23 @@ export class Database extends EventEmitter {
|
|
|
886
891
|
};
|
|
887
892
|
}
|
|
888
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Create or update a resource in the database
|
|
896
|
+
* @param {Object} config - Resource configuration
|
|
897
|
+
* @param {string} config.name - Resource name
|
|
898
|
+
* @param {Object} config.attributes - Resource attributes schema
|
|
899
|
+
* @param {string} [config.behavior='user-managed'] - Resource behavior strategy
|
|
900
|
+
* @param {Object} [config.hooks] - Resource hooks
|
|
901
|
+
* @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
|
|
902
|
+
* @param {boolean} [config.timestamps=false] - Enable automatic timestamps
|
|
903
|
+
* @param {Object} [config.partitions={}] - Partition definitions
|
|
904
|
+
* @param {boolean} [config.paranoid=true] - Security flag for dangerous operations
|
|
905
|
+
* @param {boolean} [config.cache=false] - Enable caching
|
|
906
|
+
* @param {boolean} [config.autoDecrypt=true] - Auto-decrypt secret fields
|
|
907
|
+
* @param {Function|number} [config.idGenerator] - Custom ID generator or size
|
|
908
|
+
* @param {number} [config.idSize=22] - Size for auto-generated IDs
|
|
909
|
+
* @returns {Promise<Resource>} The created or updated resource
|
|
910
|
+
*/
|
|
889
911
|
async createResource({ name, attributes, behavior = 'user-managed', hooks, ...config }) {
|
|
890
912
|
if (this.resources[name]) {
|
|
891
913
|
const existingResource = this.resources[name];
|
|
@@ -945,6 +967,7 @@ export class Database extends EventEmitter {
|
|
|
945
967
|
map: config.map,
|
|
946
968
|
idGenerator: config.idGenerator,
|
|
947
969
|
idSize: config.idSize,
|
|
970
|
+
asyncEvents: config.asyncEvents,
|
|
948
971
|
events: config.events || {}
|
|
949
972
|
});
|
|
950
973
|
resource.database = this;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BaseBackupDriver - Abstract base class for backup drivers
|
|
3
|
+
*
|
|
4
|
+
* Defines the interface that all backup drivers must implement.
|
|
5
|
+
* Each driver handles a specific destination type (filesystem, S3, etc.)
|
|
6
|
+
*/
|
|
7
|
+
export default class BaseBackupDriver {
|
|
8
|
+
constructor(config = {}) {
|
|
9
|
+
this.config = {
|
|
10
|
+
compression: 'gzip',
|
|
11
|
+
encryption: null,
|
|
12
|
+
verbose: false,
|
|
13
|
+
...config
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize the driver
|
|
19
|
+
* @param {Database} database - S3DB database instance
|
|
20
|
+
*/
|
|
21
|
+
async setup(database) {
|
|
22
|
+
this.database = database;
|
|
23
|
+
await this.onSetup();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Override this method to perform driver-specific setup
|
|
28
|
+
*/
|
|
29
|
+
async onSetup() {
|
|
30
|
+
// Override in subclasses
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Upload a backup file to the destination
|
|
35
|
+
* @param {string} filePath - Path to the backup file
|
|
36
|
+
* @param {string} backupId - Unique backup identifier
|
|
37
|
+
* @param {Object} manifest - Backup manifest with metadata
|
|
38
|
+
* @returns {Object} Upload result with destination info
|
|
39
|
+
*/
|
|
40
|
+
async upload(filePath, backupId, manifest) {
|
|
41
|
+
throw new Error('upload() method must be implemented by subclass');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Download a backup file from the destination
|
|
46
|
+
* @param {string} backupId - Unique backup identifier
|
|
47
|
+
* @param {string} targetPath - Local path to save the backup
|
|
48
|
+
* @param {Object} metadata - Backup metadata
|
|
49
|
+
* @returns {string} Path to downloaded file
|
|
50
|
+
*/
|
|
51
|
+
async download(backupId, targetPath, metadata) {
|
|
52
|
+
throw new Error('download() method must be implemented by subclass');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete a backup from the destination
|
|
57
|
+
* @param {string} backupId - Unique backup identifier
|
|
58
|
+
* @param {Object} metadata - Backup metadata
|
|
59
|
+
*/
|
|
60
|
+
async delete(backupId, metadata) {
|
|
61
|
+
throw new Error('delete() method must be implemented by subclass');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* List backups available in the destination
|
|
66
|
+
* @param {Object} options - List options (limit, prefix, etc.)
|
|
67
|
+
* @returns {Array} List of backup metadata
|
|
68
|
+
*/
|
|
69
|
+
async list(options = {}) {
|
|
70
|
+
throw new Error('list() method must be implemented by subclass');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify backup integrity
|
|
75
|
+
* @param {string} backupId - Unique backup identifier
|
|
76
|
+
* @param {string} expectedChecksum - Expected file checksum
|
|
77
|
+
* @param {Object} metadata - Backup metadata
|
|
78
|
+
* @returns {boolean} True if backup is valid
|
|
79
|
+
*/
|
|
80
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
81
|
+
throw new Error('verify() method must be implemented by subclass');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get driver type identifier
|
|
86
|
+
* @returns {string} Driver type
|
|
87
|
+
*/
|
|
88
|
+
getType() {
|
|
89
|
+
throw new Error('getType() method must be implemented by subclass');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get driver-specific storage info
|
|
94
|
+
* @returns {Object} Storage information
|
|
95
|
+
*/
|
|
96
|
+
getStorageInfo() {
|
|
97
|
+
return {
|
|
98
|
+
type: this.getType(),
|
|
99
|
+
config: this.config
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clean up resources
|
|
105
|
+
*/
|
|
106
|
+
async cleanup() {
|
|
107
|
+
// Override in subclasses if needed
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Log message if verbose mode is enabled
|
|
112
|
+
* @param {string} message - Message to log
|
|
113
|
+
*/
|
|
114
|
+
log(message) {
|
|
115
|
+
if (this.config.verbose) {
|
|
116
|
+
console.log(`[${this.getType()}BackupDriver] ${message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import BaseBackupDriver from './base-backup-driver.class.js';
|
|
2
|
+
import { mkdir, copyFile, unlink, readdir, stat, access } from 'fs/promises';
|
|
3
|
+
import { createReadStream, createWriteStream } from 'fs';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import tryFn from '../../concerns/try-fn.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* FilesystemBackupDriver - Stores backups on local/network filesystem
|
|
11
|
+
*
|
|
12
|
+
* Configuration:
|
|
13
|
+
* - path: Base directory for backups (supports template variables)
|
|
14
|
+
* - permissions: File permissions (default: 0o644)
|
|
15
|
+
* - directoryPermissions: Directory permissions (default: 0o755)
|
|
16
|
+
*/
|
|
17
|
+
export default class FilesystemBackupDriver extends BaseBackupDriver {
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
super({
|
|
20
|
+
path: './backups/{date}/',
|
|
21
|
+
permissions: 0o644,
|
|
22
|
+
directoryPermissions: 0o755,
|
|
23
|
+
...config
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getType() {
|
|
28
|
+
return 'filesystem';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async onSetup() {
|
|
32
|
+
// Validate path configuration
|
|
33
|
+
if (!this.config.path) {
|
|
34
|
+
throw new Error('FilesystemBackupDriver: path configuration is required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.log(`Initialized with path: ${this.config.path}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve path template variables
|
|
42
|
+
* @param {string} backupId - Backup identifier
|
|
43
|
+
* @param {Object} manifest - Backup manifest
|
|
44
|
+
* @returns {string} Resolved path
|
|
45
|
+
*/
|
|
46
|
+
resolvePath(backupId, manifest = {}) {
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
49
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, '-'); // HH-MM-SS
|
|
50
|
+
|
|
51
|
+
return this.config.path
|
|
52
|
+
.replace('{date}', dateStr)
|
|
53
|
+
.replace('{time}', timeStr)
|
|
54
|
+
.replace('{year}', now.getFullYear().toString())
|
|
55
|
+
.replace('{month}', (now.getMonth() + 1).toString().padStart(2, '0'))
|
|
56
|
+
.replace('{day}', now.getDate().toString().padStart(2, '0'))
|
|
57
|
+
.replace('{backupId}', backupId)
|
|
58
|
+
.replace('{type}', manifest.type || 'backup');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async upload(filePath, backupId, manifest) {
|
|
62
|
+
const targetDir = this.resolvePath(backupId, manifest);
|
|
63
|
+
const targetPath = path.join(targetDir, `${backupId}.backup`);
|
|
64
|
+
const manifestPath = path.join(targetDir, `${backupId}.manifest.json`);
|
|
65
|
+
|
|
66
|
+
// Create target directory
|
|
67
|
+
const [createDirOk, createDirErr] = await tryFn(() =>
|
|
68
|
+
mkdir(targetDir, { recursive: true, mode: this.config.directoryPermissions })
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (!createDirOk) {
|
|
72
|
+
throw new Error(`Failed to create backup directory: ${createDirErr.message}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Copy backup file
|
|
76
|
+
const [copyOk, copyErr] = await tryFn(() => copyFile(filePath, targetPath));
|
|
77
|
+
if (!copyOk) {
|
|
78
|
+
throw new Error(`Failed to copy backup file: ${copyErr.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Write manifest
|
|
82
|
+
const [manifestOk, manifestErr] = await tryFn(() =>
|
|
83
|
+
import('fs/promises').then(fs => fs.writeFile(
|
|
84
|
+
manifestPath,
|
|
85
|
+
JSON.stringify(manifest, null, 2),
|
|
86
|
+
{ mode: this.config.permissions }
|
|
87
|
+
))
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!manifestOk) {
|
|
91
|
+
// Clean up backup file if manifest fails
|
|
92
|
+
await tryFn(() => unlink(targetPath));
|
|
93
|
+
throw new Error(`Failed to write manifest: ${manifestErr.message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get file stats
|
|
97
|
+
const [statOk, , stats] = await tryFn(() => stat(targetPath));
|
|
98
|
+
const size = statOk ? stats.size : 0;
|
|
99
|
+
|
|
100
|
+
this.log(`Uploaded backup ${backupId} to ${targetPath} (${size} bytes)`);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
path: targetPath,
|
|
104
|
+
manifestPath,
|
|
105
|
+
size,
|
|
106
|
+
uploadedAt: new Date().toISOString()
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async download(backupId, targetPath, metadata) {
|
|
111
|
+
const sourcePath = metadata.path || path.join(
|
|
112
|
+
this.resolvePath(backupId, metadata),
|
|
113
|
+
`${backupId}.backup`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Check if source exists
|
|
117
|
+
const [existsOk] = await tryFn(() => access(sourcePath));
|
|
118
|
+
if (!existsOk) {
|
|
119
|
+
throw new Error(`Backup file not found: ${sourcePath}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Create target directory if needed
|
|
123
|
+
const targetDir = path.dirname(targetPath);
|
|
124
|
+
await tryFn(() => mkdir(targetDir, { recursive: true }));
|
|
125
|
+
|
|
126
|
+
// Copy file
|
|
127
|
+
const [copyOk, copyErr] = await tryFn(() => copyFile(sourcePath, targetPath));
|
|
128
|
+
if (!copyOk) {
|
|
129
|
+
throw new Error(`Failed to download backup: ${copyErr.message}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.log(`Downloaded backup ${backupId} from ${sourcePath} to ${targetPath}`);
|
|
133
|
+
return targetPath;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async delete(backupId, metadata) {
|
|
137
|
+
const backupPath = metadata.path || path.join(
|
|
138
|
+
this.resolvePath(backupId, metadata),
|
|
139
|
+
`${backupId}.backup`
|
|
140
|
+
);
|
|
141
|
+
const manifestPath = metadata.manifestPath || path.join(
|
|
142
|
+
this.resolvePath(backupId, metadata),
|
|
143
|
+
`${backupId}.manifest.json`
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Delete backup file
|
|
147
|
+
const [deleteBackupOk] = await tryFn(() => unlink(backupPath));
|
|
148
|
+
|
|
149
|
+
// Delete manifest file
|
|
150
|
+
const [deleteManifestOk] = await tryFn(() => unlink(manifestPath));
|
|
151
|
+
|
|
152
|
+
if (!deleteBackupOk && !deleteManifestOk) {
|
|
153
|
+
throw new Error(`Failed to delete backup files for ${backupId}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.log(`Deleted backup ${backupId}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async list(options = {}) {
|
|
160
|
+
const { limit = 50, prefix = '' } = options;
|
|
161
|
+
const basePath = this.resolvePath('*').replace('*', '');
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const results = [];
|
|
165
|
+
await this._scanDirectory(path.dirname(basePath), prefix, results, limit);
|
|
166
|
+
|
|
167
|
+
// Sort by creation time (newest first)
|
|
168
|
+
results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
169
|
+
|
|
170
|
+
return results.slice(0, limit);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.log(`Error listing backups: ${error.message}`);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async _scanDirectory(dirPath, prefix, results, limit) {
|
|
178
|
+
if (results.length >= limit) return;
|
|
179
|
+
|
|
180
|
+
const [readDirOk, , files] = await tryFn(() => readdir(dirPath));
|
|
181
|
+
if (!readDirOk) return;
|
|
182
|
+
|
|
183
|
+
for (const file of files) {
|
|
184
|
+
if (results.length >= limit) break;
|
|
185
|
+
|
|
186
|
+
const fullPath = path.join(dirPath, file);
|
|
187
|
+
const [statOk, , stats] = await tryFn(() => stat(fullPath));
|
|
188
|
+
|
|
189
|
+
if (!statOk) continue;
|
|
190
|
+
|
|
191
|
+
if (stats.isDirectory()) {
|
|
192
|
+
await this._scanDirectory(fullPath, prefix, results, limit);
|
|
193
|
+
} else if (file.endsWith('.manifest.json')) {
|
|
194
|
+
// Read manifest to get backup info
|
|
195
|
+
const [readOk, , content] = await tryFn(() =>
|
|
196
|
+
import('fs/promises').then(fs => fs.readFile(fullPath, 'utf8'))
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (readOk) {
|
|
200
|
+
try {
|
|
201
|
+
const manifest = JSON.parse(content);
|
|
202
|
+
const backupId = file.replace('.manifest.json', '');
|
|
203
|
+
|
|
204
|
+
if (!prefix || backupId.includes(prefix)) {
|
|
205
|
+
results.push({
|
|
206
|
+
id: backupId,
|
|
207
|
+
path: fullPath.replace('.manifest.json', '.backup'),
|
|
208
|
+
manifestPath: fullPath,
|
|
209
|
+
size: stats.size,
|
|
210
|
+
createdAt: manifest.createdAt || stats.birthtime.toISOString(),
|
|
211
|
+
...manifest
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} catch (parseErr) {
|
|
215
|
+
this.log(`Failed to parse manifest ${fullPath}: ${parseErr.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
223
|
+
const backupPath = metadata.path || path.join(
|
|
224
|
+
this.resolvePath(backupId, metadata),
|
|
225
|
+
`${backupId}.backup`
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const [readOk, readErr] = await tryFn(async () => {
|
|
229
|
+
const hash = crypto.createHash('sha256');
|
|
230
|
+
const stream = createReadStream(backupPath);
|
|
231
|
+
|
|
232
|
+
await pipeline(stream, hash);
|
|
233
|
+
const actualChecksum = hash.digest('hex');
|
|
234
|
+
|
|
235
|
+
return actualChecksum === expectedChecksum;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!readOk) {
|
|
239
|
+
this.log(`Verification failed for ${backupId}: ${readErr.message}`);
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return readOk;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
getStorageInfo() {
|
|
247
|
+
return {
|
|
248
|
+
...super.getStorageInfo(),
|
|
249
|
+
path: this.config.path,
|
|
250
|
+
permissions: this.config.permissions,
|
|
251
|
+
directoryPermissions: this.config.directoryPermissions
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import BaseBackupDriver from './base-backup-driver.class.js';
|
|
2
|
+
import FilesystemBackupDriver from './filesystem-backup-driver.class.js';
|
|
3
|
+
import S3BackupDriver from './s3-backup-driver.class.js';
|
|
4
|
+
import MultiBackupDriver from './multi-backup-driver.class.js';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
BaseBackupDriver,
|
|
8
|
+
FilesystemBackupDriver,
|
|
9
|
+
S3BackupDriver,
|
|
10
|
+
MultiBackupDriver
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Available backup drivers
|
|
15
|
+
*/
|
|
16
|
+
export const BACKUP_DRIVERS = {
|
|
17
|
+
filesystem: FilesystemBackupDriver,
|
|
18
|
+
s3: S3BackupDriver,
|
|
19
|
+
multi: MultiBackupDriver
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a backup driver instance based on driver type
|
|
24
|
+
* @param {string} driver - Driver type (filesystem, s3, multi)
|
|
25
|
+
* @param {Object} config - Driver configuration
|
|
26
|
+
* @returns {BaseBackupDriver} Driver instance
|
|
27
|
+
*/
|
|
28
|
+
export function createBackupDriver(driver, config = {}) {
|
|
29
|
+
const DriverClass = BACKUP_DRIVERS[driver];
|
|
30
|
+
|
|
31
|
+
if (!DriverClass) {
|
|
32
|
+
throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return new DriverClass(config);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate backup driver configuration
|
|
40
|
+
* @param {string} driver - Driver type
|
|
41
|
+
* @param {Object} config - Driver configuration
|
|
42
|
+
* @throws {Error} If configuration is invalid
|
|
43
|
+
*/
|
|
44
|
+
export function validateBackupConfig(driver, config = {}) {
|
|
45
|
+
if (!driver || typeof driver !== 'string') {
|
|
46
|
+
throw new Error('Driver type must be a non-empty string');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!BACKUP_DRIVERS[driver]) {
|
|
50
|
+
throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Driver-specific validation
|
|
54
|
+
switch (driver) {
|
|
55
|
+
case 'filesystem':
|
|
56
|
+
if (!config.path) {
|
|
57
|
+
throw new Error('FilesystemBackupDriver requires "path" configuration');
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case 's3':
|
|
62
|
+
// S3 driver can use database client/bucket, so no strict validation here
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'multi':
|
|
66
|
+
if (!Array.isArray(config.destinations) || config.destinations.length === 0) {
|
|
67
|
+
throw new Error('MultiBackupDriver requires non-empty "destinations" array');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate each destination
|
|
71
|
+
config.destinations.forEach((dest, index) => {
|
|
72
|
+
if (!dest.driver) {
|
|
73
|
+
throw new Error(`Destination ${index} must have a "driver" property`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Recursive validation for nested drivers
|
|
77
|
+
if (dest.driver !== 'multi') { // Prevent infinite recursion
|
|
78
|
+
validateBackupConfig(dest.driver, dest.config || {});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|