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/dist/s3db.cjs.js
CHANGED
|
@@ -4,12 +4,12 @@ Object.defineProperty(exports, '__esModule', { value: true });
|
|
|
4
4
|
|
|
5
5
|
var nanoid = require('nanoid');
|
|
6
6
|
var EventEmitter = require('events');
|
|
7
|
+
var promises = require('fs/promises');
|
|
7
8
|
var fs = require('fs');
|
|
8
|
-
var zlib = require('node:zlib');
|
|
9
9
|
var promises$1 = require('stream/promises');
|
|
10
|
-
var promises = require('fs/promises');
|
|
11
10
|
var path = require('path');
|
|
12
11
|
var crypto = require('crypto');
|
|
12
|
+
var zlib = require('node:zlib');
|
|
13
13
|
var stream = require('stream');
|
|
14
14
|
var promisePool = require('@supercharge/promise-pool');
|
|
15
15
|
var web = require('node:stream/web');
|
|
@@ -1097,11 +1097,797 @@ class AuditPlugin extends Plugin {
|
|
|
1097
1097
|
}
|
|
1098
1098
|
}
|
|
1099
1099
|
|
|
1100
|
+
class BaseBackupDriver {
|
|
1101
|
+
constructor(config = {}) {
|
|
1102
|
+
this.config = {
|
|
1103
|
+
compression: "gzip",
|
|
1104
|
+
encryption: null,
|
|
1105
|
+
verbose: false,
|
|
1106
|
+
...config
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Initialize the driver
|
|
1111
|
+
* @param {Database} database - S3DB database instance
|
|
1112
|
+
*/
|
|
1113
|
+
async setup(database) {
|
|
1114
|
+
this.database = database;
|
|
1115
|
+
await this.onSetup();
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Override this method to perform driver-specific setup
|
|
1119
|
+
*/
|
|
1120
|
+
async onSetup() {
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Upload a backup file to the destination
|
|
1124
|
+
* @param {string} filePath - Path to the backup file
|
|
1125
|
+
* @param {string} backupId - Unique backup identifier
|
|
1126
|
+
* @param {Object} manifest - Backup manifest with metadata
|
|
1127
|
+
* @returns {Object} Upload result with destination info
|
|
1128
|
+
*/
|
|
1129
|
+
async upload(filePath, backupId, manifest) {
|
|
1130
|
+
throw new Error("upload() method must be implemented by subclass");
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Download a backup file from the destination
|
|
1134
|
+
* @param {string} backupId - Unique backup identifier
|
|
1135
|
+
* @param {string} targetPath - Local path to save the backup
|
|
1136
|
+
* @param {Object} metadata - Backup metadata
|
|
1137
|
+
* @returns {string} Path to downloaded file
|
|
1138
|
+
*/
|
|
1139
|
+
async download(backupId, targetPath, metadata) {
|
|
1140
|
+
throw new Error("download() method must be implemented by subclass");
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Delete a backup from the destination
|
|
1144
|
+
* @param {string} backupId - Unique backup identifier
|
|
1145
|
+
* @param {Object} metadata - Backup metadata
|
|
1146
|
+
*/
|
|
1147
|
+
async delete(backupId, metadata) {
|
|
1148
|
+
throw new Error("delete() method must be implemented by subclass");
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* List backups available in the destination
|
|
1152
|
+
* @param {Object} options - List options (limit, prefix, etc.)
|
|
1153
|
+
* @returns {Array} List of backup metadata
|
|
1154
|
+
*/
|
|
1155
|
+
async list(options = {}) {
|
|
1156
|
+
throw new Error("list() method must be implemented by subclass");
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Verify backup integrity
|
|
1160
|
+
* @param {string} backupId - Unique backup identifier
|
|
1161
|
+
* @param {string} expectedChecksum - Expected file checksum
|
|
1162
|
+
* @param {Object} metadata - Backup metadata
|
|
1163
|
+
* @returns {boolean} True if backup is valid
|
|
1164
|
+
*/
|
|
1165
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
1166
|
+
throw new Error("verify() method must be implemented by subclass");
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Get driver type identifier
|
|
1170
|
+
* @returns {string} Driver type
|
|
1171
|
+
*/
|
|
1172
|
+
getType() {
|
|
1173
|
+
throw new Error("getType() method must be implemented by subclass");
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Get driver-specific storage info
|
|
1177
|
+
* @returns {Object} Storage information
|
|
1178
|
+
*/
|
|
1179
|
+
getStorageInfo() {
|
|
1180
|
+
return {
|
|
1181
|
+
type: this.getType(),
|
|
1182
|
+
config: this.config
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Clean up resources
|
|
1187
|
+
*/
|
|
1188
|
+
async cleanup() {
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Log message if verbose mode is enabled
|
|
1192
|
+
* @param {string} message - Message to log
|
|
1193
|
+
*/
|
|
1194
|
+
log(message) {
|
|
1195
|
+
if (this.config.verbose) {
|
|
1196
|
+
console.log(`[${this.getType()}BackupDriver] ${message}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
class FilesystemBackupDriver extends BaseBackupDriver {
|
|
1202
|
+
constructor(config = {}) {
|
|
1203
|
+
super({
|
|
1204
|
+
path: "./backups/{date}/",
|
|
1205
|
+
permissions: 420,
|
|
1206
|
+
directoryPermissions: 493,
|
|
1207
|
+
...config
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
getType() {
|
|
1211
|
+
return "filesystem";
|
|
1212
|
+
}
|
|
1213
|
+
async onSetup() {
|
|
1214
|
+
if (!this.config.path) {
|
|
1215
|
+
throw new Error("FilesystemBackupDriver: path configuration is required");
|
|
1216
|
+
}
|
|
1217
|
+
this.log(`Initialized with path: ${this.config.path}`);
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Resolve path template variables
|
|
1221
|
+
* @param {string} backupId - Backup identifier
|
|
1222
|
+
* @param {Object} manifest - Backup manifest
|
|
1223
|
+
* @returns {string} Resolved path
|
|
1224
|
+
*/
|
|
1225
|
+
resolvePath(backupId, manifest = {}) {
|
|
1226
|
+
const now = /* @__PURE__ */ new Date();
|
|
1227
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
1228
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
1229
|
+
return this.config.path.replace("{date}", dateStr).replace("{time}", timeStr).replace("{year}", now.getFullYear().toString()).replace("{month}", (now.getMonth() + 1).toString().padStart(2, "0")).replace("{day}", now.getDate().toString().padStart(2, "0")).replace("{backupId}", backupId).replace("{type}", manifest.type || "backup");
|
|
1230
|
+
}
|
|
1231
|
+
async upload(filePath, backupId, manifest) {
|
|
1232
|
+
const targetDir = this.resolvePath(backupId, manifest);
|
|
1233
|
+
const targetPath = path.join(targetDir, `${backupId}.backup`);
|
|
1234
|
+
const manifestPath = path.join(targetDir, `${backupId}.manifest.json`);
|
|
1235
|
+
const [createDirOk, createDirErr] = await tryFn(
|
|
1236
|
+
() => promises.mkdir(targetDir, { recursive: true, mode: this.config.directoryPermissions })
|
|
1237
|
+
);
|
|
1238
|
+
if (!createDirOk) {
|
|
1239
|
+
throw new Error(`Failed to create backup directory: ${createDirErr.message}`);
|
|
1240
|
+
}
|
|
1241
|
+
const [copyOk, copyErr] = await tryFn(() => promises.copyFile(filePath, targetPath));
|
|
1242
|
+
if (!copyOk) {
|
|
1243
|
+
throw new Error(`Failed to copy backup file: ${copyErr.message}`);
|
|
1244
|
+
}
|
|
1245
|
+
const [manifestOk, manifestErr] = await tryFn(
|
|
1246
|
+
() => import('fs/promises').then((fs) => fs.writeFile(
|
|
1247
|
+
manifestPath,
|
|
1248
|
+
JSON.stringify(manifest, null, 2),
|
|
1249
|
+
{ mode: this.config.permissions }
|
|
1250
|
+
))
|
|
1251
|
+
);
|
|
1252
|
+
if (!manifestOk) {
|
|
1253
|
+
await tryFn(() => promises.unlink(targetPath));
|
|
1254
|
+
throw new Error(`Failed to write manifest: ${manifestErr.message}`);
|
|
1255
|
+
}
|
|
1256
|
+
const [statOk, , stats] = await tryFn(() => promises.stat(targetPath));
|
|
1257
|
+
const size = statOk ? stats.size : 0;
|
|
1258
|
+
this.log(`Uploaded backup ${backupId} to ${targetPath} (${size} bytes)`);
|
|
1259
|
+
return {
|
|
1260
|
+
path: targetPath,
|
|
1261
|
+
manifestPath,
|
|
1262
|
+
size,
|
|
1263
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
async download(backupId, targetPath, metadata) {
|
|
1267
|
+
const sourcePath = metadata.path || path.join(
|
|
1268
|
+
this.resolvePath(backupId, metadata),
|
|
1269
|
+
`${backupId}.backup`
|
|
1270
|
+
);
|
|
1271
|
+
const [existsOk] = await tryFn(() => promises.access(sourcePath));
|
|
1272
|
+
if (!existsOk) {
|
|
1273
|
+
throw new Error(`Backup file not found: ${sourcePath}`);
|
|
1274
|
+
}
|
|
1275
|
+
const targetDir = path.dirname(targetPath);
|
|
1276
|
+
await tryFn(() => promises.mkdir(targetDir, { recursive: true }));
|
|
1277
|
+
const [copyOk, copyErr] = await tryFn(() => promises.copyFile(sourcePath, targetPath));
|
|
1278
|
+
if (!copyOk) {
|
|
1279
|
+
throw new Error(`Failed to download backup: ${copyErr.message}`);
|
|
1280
|
+
}
|
|
1281
|
+
this.log(`Downloaded backup ${backupId} from ${sourcePath} to ${targetPath}`);
|
|
1282
|
+
return targetPath;
|
|
1283
|
+
}
|
|
1284
|
+
async delete(backupId, metadata) {
|
|
1285
|
+
const backupPath = metadata.path || path.join(
|
|
1286
|
+
this.resolvePath(backupId, metadata),
|
|
1287
|
+
`${backupId}.backup`
|
|
1288
|
+
);
|
|
1289
|
+
const manifestPath = metadata.manifestPath || path.join(
|
|
1290
|
+
this.resolvePath(backupId, metadata),
|
|
1291
|
+
`${backupId}.manifest.json`
|
|
1292
|
+
);
|
|
1293
|
+
const [deleteBackupOk] = await tryFn(() => promises.unlink(backupPath));
|
|
1294
|
+
const [deleteManifestOk] = await tryFn(() => promises.unlink(manifestPath));
|
|
1295
|
+
if (!deleteBackupOk && !deleteManifestOk) {
|
|
1296
|
+
throw new Error(`Failed to delete backup files for ${backupId}`);
|
|
1297
|
+
}
|
|
1298
|
+
this.log(`Deleted backup ${backupId}`);
|
|
1299
|
+
}
|
|
1300
|
+
async list(options = {}) {
|
|
1301
|
+
const { limit = 50, prefix = "" } = options;
|
|
1302
|
+
const basePath = this.resolvePath("*").replace("*", "");
|
|
1303
|
+
try {
|
|
1304
|
+
const results = [];
|
|
1305
|
+
await this._scanDirectory(path.dirname(basePath), prefix, results, limit);
|
|
1306
|
+
results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
1307
|
+
return results.slice(0, limit);
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
this.log(`Error listing backups: ${error.message}`);
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
async _scanDirectory(dirPath, prefix, results, limit) {
|
|
1314
|
+
if (results.length >= limit) return;
|
|
1315
|
+
const [readDirOk, , files] = await tryFn(() => promises.readdir(dirPath));
|
|
1316
|
+
if (!readDirOk) return;
|
|
1317
|
+
for (const file of files) {
|
|
1318
|
+
if (results.length >= limit) break;
|
|
1319
|
+
const fullPath = path.join(dirPath, file);
|
|
1320
|
+
const [statOk, , stats] = await tryFn(() => promises.stat(fullPath));
|
|
1321
|
+
if (!statOk) continue;
|
|
1322
|
+
if (stats.isDirectory()) {
|
|
1323
|
+
await this._scanDirectory(fullPath, prefix, results, limit);
|
|
1324
|
+
} else if (file.endsWith(".manifest.json")) {
|
|
1325
|
+
const [readOk, , content] = await tryFn(
|
|
1326
|
+
() => import('fs/promises').then((fs) => fs.readFile(fullPath, "utf8"))
|
|
1327
|
+
);
|
|
1328
|
+
if (readOk) {
|
|
1329
|
+
try {
|
|
1330
|
+
const manifest = JSON.parse(content);
|
|
1331
|
+
const backupId = file.replace(".manifest.json", "");
|
|
1332
|
+
if (!prefix || backupId.includes(prefix)) {
|
|
1333
|
+
results.push({
|
|
1334
|
+
id: backupId,
|
|
1335
|
+
path: fullPath.replace(".manifest.json", ".backup"),
|
|
1336
|
+
manifestPath: fullPath,
|
|
1337
|
+
size: stats.size,
|
|
1338
|
+
createdAt: manifest.createdAt || stats.birthtime.toISOString(),
|
|
1339
|
+
...manifest
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
} catch (parseErr) {
|
|
1343
|
+
this.log(`Failed to parse manifest ${fullPath}: ${parseErr.message}`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
1350
|
+
const backupPath = metadata.path || path.join(
|
|
1351
|
+
this.resolvePath(backupId, metadata),
|
|
1352
|
+
`${backupId}.backup`
|
|
1353
|
+
);
|
|
1354
|
+
const [readOk, readErr] = await tryFn(async () => {
|
|
1355
|
+
const hash = crypto.createHash("sha256");
|
|
1356
|
+
const stream = fs.createReadStream(backupPath);
|
|
1357
|
+
await promises$1.pipeline(stream, hash);
|
|
1358
|
+
const actualChecksum = hash.digest("hex");
|
|
1359
|
+
return actualChecksum === expectedChecksum;
|
|
1360
|
+
});
|
|
1361
|
+
if (!readOk) {
|
|
1362
|
+
this.log(`Verification failed for ${backupId}: ${readErr.message}`);
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
return readOk;
|
|
1366
|
+
}
|
|
1367
|
+
getStorageInfo() {
|
|
1368
|
+
return {
|
|
1369
|
+
...super.getStorageInfo(),
|
|
1370
|
+
path: this.config.path,
|
|
1371
|
+
permissions: this.config.permissions,
|
|
1372
|
+
directoryPermissions: this.config.directoryPermissions
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
class S3BackupDriver extends BaseBackupDriver {
|
|
1378
|
+
constructor(config = {}) {
|
|
1379
|
+
super({
|
|
1380
|
+
bucket: null,
|
|
1381
|
+
// Will use database bucket if not specified
|
|
1382
|
+
path: "backups/{date}/",
|
|
1383
|
+
storageClass: "STANDARD_IA",
|
|
1384
|
+
serverSideEncryption: "AES256",
|
|
1385
|
+
client: null,
|
|
1386
|
+
// Will use database client if not specified
|
|
1387
|
+
...config
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
getType() {
|
|
1391
|
+
return "s3";
|
|
1392
|
+
}
|
|
1393
|
+
async onSetup() {
|
|
1394
|
+
if (!this.config.client) {
|
|
1395
|
+
this.config.client = this.database.client;
|
|
1396
|
+
}
|
|
1397
|
+
if (!this.config.bucket) {
|
|
1398
|
+
this.config.bucket = this.database.bucket;
|
|
1399
|
+
}
|
|
1400
|
+
if (!this.config.client) {
|
|
1401
|
+
throw new Error("S3BackupDriver: client is required (either via config or database)");
|
|
1402
|
+
}
|
|
1403
|
+
if (!this.config.bucket) {
|
|
1404
|
+
throw new Error("S3BackupDriver: bucket is required (either via config or database)");
|
|
1405
|
+
}
|
|
1406
|
+
this.log(`Initialized with bucket: ${this.config.bucket}, path: ${this.config.path}`);
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Resolve S3 key template variables
|
|
1410
|
+
* @param {string} backupId - Backup identifier
|
|
1411
|
+
* @param {Object} manifest - Backup manifest
|
|
1412
|
+
* @returns {string} Resolved S3 key
|
|
1413
|
+
*/
|
|
1414
|
+
resolveKey(backupId, manifest = {}) {
|
|
1415
|
+
const now = /* @__PURE__ */ new Date();
|
|
1416
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
1417
|
+
const timeStr = now.toISOString().slice(11, 19).replace(/:/g, "-");
|
|
1418
|
+
const basePath = this.config.path.replace("{date}", dateStr).replace("{time}", timeStr).replace("{year}", now.getFullYear().toString()).replace("{month}", (now.getMonth() + 1).toString().padStart(2, "0")).replace("{day}", now.getDate().toString().padStart(2, "0")).replace("{backupId}", backupId).replace("{type}", manifest.type || "backup");
|
|
1419
|
+
return path.posix.join(basePath, `${backupId}.backup`);
|
|
1420
|
+
}
|
|
1421
|
+
resolveManifestKey(backupId, manifest = {}) {
|
|
1422
|
+
return this.resolveKey(backupId, manifest).replace(".backup", ".manifest.json");
|
|
1423
|
+
}
|
|
1424
|
+
async upload(filePath, backupId, manifest) {
|
|
1425
|
+
const backupKey = this.resolveKey(backupId, manifest);
|
|
1426
|
+
const manifestKey = this.resolveManifestKey(backupId, manifest);
|
|
1427
|
+
const [statOk, , stats] = await tryFn(() => promises.stat(filePath));
|
|
1428
|
+
const fileSize = statOk ? stats.size : 0;
|
|
1429
|
+
const [uploadOk, uploadErr] = await tryFn(async () => {
|
|
1430
|
+
const fileStream = fs.createReadStream(filePath);
|
|
1431
|
+
return await this.config.client.uploadObject({
|
|
1432
|
+
bucket: this.config.bucket,
|
|
1433
|
+
key: backupKey,
|
|
1434
|
+
body: fileStream,
|
|
1435
|
+
contentLength: fileSize,
|
|
1436
|
+
metadata: {
|
|
1437
|
+
"backup-id": backupId,
|
|
1438
|
+
"backup-type": manifest.type || "backup",
|
|
1439
|
+
"created-at": (/* @__PURE__ */ new Date()).toISOString()
|
|
1440
|
+
},
|
|
1441
|
+
storageClass: this.config.storageClass,
|
|
1442
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
1443
|
+
});
|
|
1444
|
+
});
|
|
1445
|
+
if (!uploadOk) {
|
|
1446
|
+
throw new Error(`Failed to upload backup file: ${uploadErr.message}`);
|
|
1447
|
+
}
|
|
1448
|
+
const [manifestOk, manifestErr] = await tryFn(
|
|
1449
|
+
() => this.config.client.uploadObject({
|
|
1450
|
+
bucket: this.config.bucket,
|
|
1451
|
+
key: manifestKey,
|
|
1452
|
+
body: JSON.stringify(manifest, null, 2),
|
|
1453
|
+
contentType: "application/json",
|
|
1454
|
+
metadata: {
|
|
1455
|
+
"backup-id": backupId,
|
|
1456
|
+
"manifest-for": backupKey
|
|
1457
|
+
},
|
|
1458
|
+
storageClass: this.config.storageClass,
|
|
1459
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
1460
|
+
})
|
|
1461
|
+
);
|
|
1462
|
+
if (!manifestOk) {
|
|
1463
|
+
await tryFn(() => this.config.client.deleteObject({
|
|
1464
|
+
bucket: this.config.bucket,
|
|
1465
|
+
key: backupKey
|
|
1466
|
+
}));
|
|
1467
|
+
throw new Error(`Failed to upload manifest: ${manifestErr.message}`);
|
|
1468
|
+
}
|
|
1469
|
+
this.log(`Uploaded backup ${backupId} to s3://${this.config.bucket}/${backupKey} (${fileSize} bytes)`);
|
|
1470
|
+
return {
|
|
1471
|
+
bucket: this.config.bucket,
|
|
1472
|
+
key: backupKey,
|
|
1473
|
+
manifestKey,
|
|
1474
|
+
size: fileSize,
|
|
1475
|
+
storageClass: this.config.storageClass,
|
|
1476
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1477
|
+
etag: uploadOk?.ETag
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
async download(backupId, targetPath, metadata) {
|
|
1481
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
1482
|
+
const [downloadOk, downloadErr] = await tryFn(
|
|
1483
|
+
() => this.config.client.downloadObject({
|
|
1484
|
+
bucket: this.config.bucket,
|
|
1485
|
+
key: backupKey,
|
|
1486
|
+
filePath: targetPath
|
|
1487
|
+
})
|
|
1488
|
+
);
|
|
1489
|
+
if (!downloadOk) {
|
|
1490
|
+
throw new Error(`Failed to download backup: ${downloadErr.message}`);
|
|
1491
|
+
}
|
|
1492
|
+
this.log(`Downloaded backup ${backupId} from s3://${this.config.bucket}/${backupKey} to ${targetPath}`);
|
|
1493
|
+
return targetPath;
|
|
1494
|
+
}
|
|
1495
|
+
async delete(backupId, metadata) {
|
|
1496
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
1497
|
+
const manifestKey = metadata.manifestKey || this.resolveManifestKey(backupId, metadata);
|
|
1498
|
+
const [deleteBackupOk] = await tryFn(
|
|
1499
|
+
() => this.config.client.deleteObject({
|
|
1500
|
+
bucket: this.config.bucket,
|
|
1501
|
+
key: backupKey
|
|
1502
|
+
})
|
|
1503
|
+
);
|
|
1504
|
+
const [deleteManifestOk] = await tryFn(
|
|
1505
|
+
() => this.config.client.deleteObject({
|
|
1506
|
+
bucket: this.config.bucket,
|
|
1507
|
+
key: manifestKey
|
|
1508
|
+
})
|
|
1509
|
+
);
|
|
1510
|
+
if (!deleteBackupOk && !deleteManifestOk) {
|
|
1511
|
+
throw new Error(`Failed to delete backup objects for ${backupId}`);
|
|
1512
|
+
}
|
|
1513
|
+
this.log(`Deleted backup ${backupId} from S3`);
|
|
1514
|
+
}
|
|
1515
|
+
async list(options = {}) {
|
|
1516
|
+
const { limit = 50, prefix = "" } = options;
|
|
1517
|
+
const searchPrefix = this.config.path.replace(/\{[^}]+\}/g, "");
|
|
1518
|
+
const [listOk, listErr, response] = await tryFn(
|
|
1519
|
+
() => this.config.client.listObjects({
|
|
1520
|
+
bucket: this.config.bucket,
|
|
1521
|
+
prefix: searchPrefix,
|
|
1522
|
+
maxKeys: limit * 2
|
|
1523
|
+
// Get more to account for manifest files
|
|
1524
|
+
})
|
|
1525
|
+
);
|
|
1526
|
+
if (!listOk) {
|
|
1527
|
+
this.log(`Error listing S3 objects: ${listErr.message}`);
|
|
1528
|
+
return [];
|
|
1529
|
+
}
|
|
1530
|
+
const manifestObjects = (response.Contents || []).filter((obj) => obj.Key.endsWith(".manifest.json")).filter((obj) => !prefix || obj.Key.includes(prefix));
|
|
1531
|
+
const results = [];
|
|
1532
|
+
for (const obj of manifestObjects.slice(0, limit)) {
|
|
1533
|
+
const [manifestOk, , manifestContent] = await tryFn(
|
|
1534
|
+
() => this.config.client.getObject({
|
|
1535
|
+
bucket: this.config.bucket,
|
|
1536
|
+
key: obj.Key
|
|
1537
|
+
})
|
|
1538
|
+
);
|
|
1539
|
+
if (manifestOk) {
|
|
1540
|
+
try {
|
|
1541
|
+
const manifest = JSON.parse(manifestContent);
|
|
1542
|
+
const backupId = path.basename(obj.Key, ".manifest.json");
|
|
1543
|
+
results.push({
|
|
1544
|
+
id: backupId,
|
|
1545
|
+
bucket: this.config.bucket,
|
|
1546
|
+
key: obj.Key.replace(".manifest.json", ".backup"),
|
|
1547
|
+
manifestKey: obj.Key,
|
|
1548
|
+
size: obj.Size,
|
|
1549
|
+
lastModified: obj.LastModified,
|
|
1550
|
+
storageClass: obj.StorageClass,
|
|
1551
|
+
createdAt: manifest.createdAt || obj.LastModified,
|
|
1552
|
+
...manifest
|
|
1553
|
+
});
|
|
1554
|
+
} catch (parseErr) {
|
|
1555
|
+
this.log(`Failed to parse manifest ${obj.Key}: ${parseErr.message}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
results.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
1560
|
+
return results;
|
|
1561
|
+
}
|
|
1562
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
1563
|
+
const backupKey = metadata.key || this.resolveKey(backupId, metadata);
|
|
1564
|
+
const [verifyOk, verifyErr] = await tryFn(async () => {
|
|
1565
|
+
const headResponse = await this.config.client.headObject({
|
|
1566
|
+
bucket: this.config.bucket,
|
|
1567
|
+
key: backupKey
|
|
1568
|
+
});
|
|
1569
|
+
const etag = headResponse.ETag?.replace(/"/g, "");
|
|
1570
|
+
if (etag && !etag.includes("-")) {
|
|
1571
|
+
const expectedMd5 = crypto.createHash("md5").update(expectedChecksum).digest("hex");
|
|
1572
|
+
return etag === expectedMd5;
|
|
1573
|
+
} else {
|
|
1574
|
+
const [streamOk, , stream] = await tryFn(
|
|
1575
|
+
() => this.config.client.getObjectStream({
|
|
1576
|
+
bucket: this.config.bucket,
|
|
1577
|
+
key: backupKey
|
|
1578
|
+
})
|
|
1579
|
+
);
|
|
1580
|
+
if (!streamOk) return false;
|
|
1581
|
+
const hash = crypto.createHash("sha256");
|
|
1582
|
+
for await (const chunk of stream) {
|
|
1583
|
+
hash.update(chunk);
|
|
1584
|
+
}
|
|
1585
|
+
const actualChecksum = hash.digest("hex");
|
|
1586
|
+
return actualChecksum === expectedChecksum;
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
if (!verifyOk) {
|
|
1590
|
+
this.log(`Verification failed for ${backupId}: ${verifyErr?.message || "checksum mismatch"}`);
|
|
1591
|
+
return false;
|
|
1592
|
+
}
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
getStorageInfo() {
|
|
1596
|
+
return {
|
|
1597
|
+
...super.getStorageInfo(),
|
|
1598
|
+
bucket: this.config.bucket,
|
|
1599
|
+
path: this.config.path,
|
|
1600
|
+
storageClass: this.config.storageClass,
|
|
1601
|
+
serverSideEncryption: this.config.serverSideEncryption
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
class MultiBackupDriver extends BaseBackupDriver {
|
|
1607
|
+
constructor(config = {}) {
|
|
1608
|
+
super({
|
|
1609
|
+
destinations: [],
|
|
1610
|
+
strategy: "all",
|
|
1611
|
+
// 'all', 'any', 'priority'
|
|
1612
|
+
concurrency: 3,
|
|
1613
|
+
requireAll: true,
|
|
1614
|
+
// For backward compatibility
|
|
1615
|
+
...config
|
|
1616
|
+
});
|
|
1617
|
+
this.drivers = [];
|
|
1618
|
+
}
|
|
1619
|
+
getType() {
|
|
1620
|
+
return "multi";
|
|
1621
|
+
}
|
|
1622
|
+
async onSetup() {
|
|
1623
|
+
if (!Array.isArray(this.config.destinations) || this.config.destinations.length === 0) {
|
|
1624
|
+
throw new Error("MultiBackupDriver: destinations array is required and must not be empty");
|
|
1625
|
+
}
|
|
1626
|
+
for (const [index, destConfig] of this.config.destinations.entries()) {
|
|
1627
|
+
if (!destConfig.driver) {
|
|
1628
|
+
throw new Error(`MultiBackupDriver: destination[${index}] must have a driver type`);
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const driver = createBackupDriver(destConfig.driver, destConfig.config || {});
|
|
1632
|
+
await driver.setup(this.database);
|
|
1633
|
+
this.drivers.push({
|
|
1634
|
+
driver,
|
|
1635
|
+
config: destConfig,
|
|
1636
|
+
index
|
|
1637
|
+
});
|
|
1638
|
+
this.log(`Setup destination ${index}: ${destConfig.driver}`);
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
throw new Error(`Failed to setup destination ${index} (${destConfig.driver}): ${error.message}`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (this.config.requireAll === false) {
|
|
1644
|
+
this.config.strategy = "any";
|
|
1645
|
+
}
|
|
1646
|
+
this.log(`Initialized with ${this.drivers.length} destinations, strategy: ${this.config.strategy}`);
|
|
1647
|
+
}
|
|
1648
|
+
async upload(filePath, backupId, manifest) {
|
|
1649
|
+
const strategy = this.config.strategy;
|
|
1650
|
+
const errors = [];
|
|
1651
|
+
if (strategy === "priority") {
|
|
1652
|
+
for (const { driver, config, index } of this.drivers) {
|
|
1653
|
+
const [ok, err, result] = await tryFn(
|
|
1654
|
+
() => driver.upload(filePath, backupId, manifest)
|
|
1655
|
+
);
|
|
1656
|
+
if (ok) {
|
|
1657
|
+
this.log(`Priority upload successful to destination ${index}`);
|
|
1658
|
+
return [{
|
|
1659
|
+
...result,
|
|
1660
|
+
driver: config.driver,
|
|
1661
|
+
destination: index,
|
|
1662
|
+
status: "success"
|
|
1663
|
+
}];
|
|
1664
|
+
} else {
|
|
1665
|
+
errors.push({ destination: index, error: err.message });
|
|
1666
|
+
this.log(`Priority upload failed to destination ${index}: ${err.message}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
throw new Error(`All priority destinations failed: ${errors.map((e) => `${e.destination}: ${e.error}`).join("; ")}`);
|
|
1670
|
+
}
|
|
1671
|
+
const uploadPromises = this.drivers.map(async ({ driver, config, index }) => {
|
|
1672
|
+
const [ok, err, result] = await tryFn(
|
|
1673
|
+
() => driver.upload(filePath, backupId, manifest)
|
|
1674
|
+
);
|
|
1675
|
+
if (ok) {
|
|
1676
|
+
this.log(`Upload successful to destination ${index}`);
|
|
1677
|
+
return {
|
|
1678
|
+
...result,
|
|
1679
|
+
driver: config.driver,
|
|
1680
|
+
destination: index,
|
|
1681
|
+
status: "success"
|
|
1682
|
+
};
|
|
1683
|
+
} else {
|
|
1684
|
+
this.log(`Upload failed to destination ${index}: ${err.message}`);
|
|
1685
|
+
const errorResult = {
|
|
1686
|
+
driver: config.driver,
|
|
1687
|
+
destination: index,
|
|
1688
|
+
status: "failed",
|
|
1689
|
+
error: err.message
|
|
1690
|
+
};
|
|
1691
|
+
errors.push(errorResult);
|
|
1692
|
+
return errorResult;
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
const allResults = await this._executeConcurrent(uploadPromises, this.config.concurrency);
|
|
1696
|
+
const successResults = allResults.filter((r) => r.status === "success");
|
|
1697
|
+
const failedResults = allResults.filter((r) => r.status === "failed");
|
|
1698
|
+
if (strategy === "all" && failedResults.length > 0) {
|
|
1699
|
+
throw new Error(`Some destinations failed: ${failedResults.map((r) => `${r.destination}: ${r.error}`).join("; ")}`);
|
|
1700
|
+
}
|
|
1701
|
+
if (strategy === "any" && successResults.length === 0) {
|
|
1702
|
+
throw new Error(`All destinations failed: ${failedResults.map((r) => `${r.destination}: ${r.error}`).join("; ")}`);
|
|
1703
|
+
}
|
|
1704
|
+
return allResults;
|
|
1705
|
+
}
|
|
1706
|
+
async download(backupId, targetPath, metadata) {
|
|
1707
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
1708
|
+
for (const destMetadata of destinations) {
|
|
1709
|
+
if (destMetadata.status !== "success") continue;
|
|
1710
|
+
const driverInstance = this.drivers.find((d) => d.index === destMetadata.destination);
|
|
1711
|
+
if (!driverInstance) continue;
|
|
1712
|
+
const [ok, err, result] = await tryFn(
|
|
1713
|
+
() => driverInstance.driver.download(backupId, targetPath, destMetadata)
|
|
1714
|
+
);
|
|
1715
|
+
if (ok) {
|
|
1716
|
+
this.log(`Downloaded from destination ${destMetadata.destination}`);
|
|
1717
|
+
return result;
|
|
1718
|
+
} else {
|
|
1719
|
+
this.log(`Download failed from destination ${destMetadata.destination}: ${err.message}`);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
throw new Error(`Failed to download backup from any destination`);
|
|
1723
|
+
}
|
|
1724
|
+
async delete(backupId, metadata) {
|
|
1725
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
1726
|
+
const errors = [];
|
|
1727
|
+
let successCount = 0;
|
|
1728
|
+
for (const destMetadata of destinations) {
|
|
1729
|
+
if (destMetadata.status !== "success") continue;
|
|
1730
|
+
const driverInstance = this.drivers.find((d) => d.index === destMetadata.destination);
|
|
1731
|
+
if (!driverInstance) continue;
|
|
1732
|
+
const [ok, err] = await tryFn(
|
|
1733
|
+
() => driverInstance.driver.delete(backupId, destMetadata)
|
|
1734
|
+
);
|
|
1735
|
+
if (ok) {
|
|
1736
|
+
successCount++;
|
|
1737
|
+
this.log(`Deleted from destination ${destMetadata.destination}`);
|
|
1738
|
+
} else {
|
|
1739
|
+
errors.push(`${destMetadata.destination}: ${err.message}`);
|
|
1740
|
+
this.log(`Delete failed from destination ${destMetadata.destination}: ${err.message}`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
if (successCount === 0 && errors.length > 0) {
|
|
1744
|
+
throw new Error(`Failed to delete from any destination: ${errors.join("; ")}`);
|
|
1745
|
+
}
|
|
1746
|
+
if (errors.length > 0) {
|
|
1747
|
+
this.log(`Partial delete success, some errors: ${errors.join("; ")}`);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
async list(options = {}) {
|
|
1751
|
+
const allLists = await Promise.allSettled(
|
|
1752
|
+
this.drivers.map(
|
|
1753
|
+
({ driver, index }) => driver.list(options).catch((err) => {
|
|
1754
|
+
this.log(`List failed for destination ${index}: ${err.message}`);
|
|
1755
|
+
return [];
|
|
1756
|
+
})
|
|
1757
|
+
)
|
|
1758
|
+
);
|
|
1759
|
+
const backupMap = /* @__PURE__ */ new Map();
|
|
1760
|
+
allLists.forEach((result, index) => {
|
|
1761
|
+
if (result.status === "fulfilled") {
|
|
1762
|
+
result.value.forEach((backup) => {
|
|
1763
|
+
const existing = backupMap.get(backup.id);
|
|
1764
|
+
if (!existing || new Date(backup.createdAt) > new Date(existing.createdAt)) {
|
|
1765
|
+
backupMap.set(backup.id, {
|
|
1766
|
+
...backup,
|
|
1767
|
+
destinations: existing ? [...existing.destinations || [], { destination: index, ...backup }] : [{ destination: index, ...backup }]
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
const results = Array.from(backupMap.values()).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, options.limit || 50);
|
|
1774
|
+
return results;
|
|
1775
|
+
}
|
|
1776
|
+
async verify(backupId, expectedChecksum, metadata) {
|
|
1777
|
+
const destinations = Array.isArray(metadata.destinations) ? metadata.destinations : [metadata];
|
|
1778
|
+
for (const destMetadata of destinations) {
|
|
1779
|
+
if (destMetadata.status !== "success") continue;
|
|
1780
|
+
const driverInstance = this.drivers.find((d) => d.index === destMetadata.destination);
|
|
1781
|
+
if (!driverInstance) continue;
|
|
1782
|
+
const [ok, , isValid] = await tryFn(
|
|
1783
|
+
() => driverInstance.driver.verify(backupId, expectedChecksum, destMetadata)
|
|
1784
|
+
);
|
|
1785
|
+
if (ok && isValid) {
|
|
1786
|
+
this.log(`Verification successful from destination ${destMetadata.destination}`);
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
async cleanup() {
|
|
1793
|
+
await Promise.all(
|
|
1794
|
+
this.drivers.map(
|
|
1795
|
+
({ driver }) => tryFn(() => driver.cleanup()).catch(() => {
|
|
1796
|
+
})
|
|
1797
|
+
)
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
getStorageInfo() {
|
|
1801
|
+
return {
|
|
1802
|
+
...super.getStorageInfo(),
|
|
1803
|
+
strategy: this.config.strategy,
|
|
1804
|
+
destinations: this.drivers.map(({ driver, config, index }) => ({
|
|
1805
|
+
index,
|
|
1806
|
+
driver: config.driver,
|
|
1807
|
+
info: driver.getStorageInfo()
|
|
1808
|
+
}))
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Execute promises with concurrency limit
|
|
1813
|
+
* @param {Array} promises - Array of promise functions
|
|
1814
|
+
* @param {number} concurrency - Max concurrent executions
|
|
1815
|
+
* @returns {Array} Results in original order
|
|
1816
|
+
*/
|
|
1817
|
+
async _executeConcurrent(promises, concurrency) {
|
|
1818
|
+
const results = new Array(promises.length);
|
|
1819
|
+
const executing = [];
|
|
1820
|
+
for (let i = 0; i < promises.length; i++) {
|
|
1821
|
+
const promise = Promise.resolve(promises[i]).then((result) => {
|
|
1822
|
+
results[i] = result;
|
|
1823
|
+
return result;
|
|
1824
|
+
});
|
|
1825
|
+
executing.push(promise);
|
|
1826
|
+
if (executing.length >= concurrency) {
|
|
1827
|
+
await Promise.race(executing);
|
|
1828
|
+
executing.splice(executing.findIndex((p) => p === promise), 1);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
await Promise.all(executing);
|
|
1832
|
+
return results;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const BACKUP_DRIVERS = {
|
|
1837
|
+
filesystem: FilesystemBackupDriver,
|
|
1838
|
+
s3: S3BackupDriver,
|
|
1839
|
+
multi: MultiBackupDriver
|
|
1840
|
+
};
|
|
1841
|
+
function createBackupDriver(driver, config = {}) {
|
|
1842
|
+
const DriverClass = BACKUP_DRIVERS[driver];
|
|
1843
|
+
if (!DriverClass) {
|
|
1844
|
+
throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`);
|
|
1845
|
+
}
|
|
1846
|
+
return new DriverClass(config);
|
|
1847
|
+
}
|
|
1848
|
+
function validateBackupConfig(driver, config = {}) {
|
|
1849
|
+
if (!driver || typeof driver !== "string") {
|
|
1850
|
+
throw new Error("Driver type must be a non-empty string");
|
|
1851
|
+
}
|
|
1852
|
+
if (!BACKUP_DRIVERS[driver]) {
|
|
1853
|
+
throw new Error(`Unknown backup driver: ${driver}. Available drivers: ${Object.keys(BACKUP_DRIVERS).join(", ")}`);
|
|
1854
|
+
}
|
|
1855
|
+
switch (driver) {
|
|
1856
|
+
case "filesystem":
|
|
1857
|
+
if (!config.path) {
|
|
1858
|
+
throw new Error('FilesystemBackupDriver requires "path" configuration');
|
|
1859
|
+
}
|
|
1860
|
+
break;
|
|
1861
|
+
case "s3":
|
|
1862
|
+
break;
|
|
1863
|
+
case "multi":
|
|
1864
|
+
if (!Array.isArray(config.destinations) || config.destinations.length === 0) {
|
|
1865
|
+
throw new Error('MultiBackupDriver requires non-empty "destinations" array');
|
|
1866
|
+
}
|
|
1867
|
+
config.destinations.forEach((dest, index) => {
|
|
1868
|
+
if (!dest.driver) {
|
|
1869
|
+
throw new Error(`Destination ${index} must have a "driver" property`);
|
|
1870
|
+
}
|
|
1871
|
+
if (dest.driver !== "multi") {
|
|
1872
|
+
validateBackupConfig(dest.driver, dest.config || {});
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1100
1880
|
class BackupPlugin extends Plugin {
|
|
1101
1881
|
constructor(options = {}) {
|
|
1102
1882
|
super();
|
|
1883
|
+
this.driverName = options.driver || "filesystem";
|
|
1884
|
+
this.driverConfig = options.config || {};
|
|
1103
1885
|
this.config = {
|
|
1886
|
+
// Legacy destinations support (will be converted to multi driver)
|
|
1887
|
+
destinations: options.destinations || null,
|
|
1888
|
+
// Scheduling configuration
|
|
1104
1889
|
schedule: options.schedule || {},
|
|
1890
|
+
// Retention policy (Grandfather-Father-Son)
|
|
1105
1891
|
retention: {
|
|
1106
1892
|
daily: 7,
|
|
1107
1893
|
weekly: 4,
|
|
@@ -1109,7 +1895,7 @@ class BackupPlugin extends Plugin {
|
|
|
1109
1895
|
yearly: 3,
|
|
1110
1896
|
...options.retention
|
|
1111
1897
|
},
|
|
1112
|
-
|
|
1898
|
+
// Backup options
|
|
1113
1899
|
compression: options.compression || "gzip",
|
|
1114
1900
|
encryption: options.encryption || null,
|
|
1115
1901
|
verification: options.verification !== false,
|
|
@@ -1119,39 +1905,62 @@ class BackupPlugin extends Plugin {
|
|
|
1119
1905
|
backupMetadataResource: options.backupMetadataResource || "backup_metadata",
|
|
1120
1906
|
tempDir: options.tempDir || "./tmp/backups",
|
|
1121
1907
|
verbose: options.verbose || false,
|
|
1908
|
+
// Hooks
|
|
1122
1909
|
onBackupStart: options.onBackupStart || null,
|
|
1123
1910
|
onBackupComplete: options.onBackupComplete || null,
|
|
1124
1911
|
onBackupError: options.onBackupError || null,
|
|
1125
|
-
|
|
1912
|
+
onRestoreStart: options.onRestoreStart || null,
|
|
1913
|
+
onRestoreComplete: options.onRestoreComplete || null,
|
|
1914
|
+
onRestoreError: options.onRestoreError || null
|
|
1126
1915
|
};
|
|
1127
|
-
this.
|
|
1128
|
-
this.scheduledJobs = /* @__PURE__ */ new Map();
|
|
1916
|
+
this.driver = null;
|
|
1129
1917
|
this.activeBackups = /* @__PURE__ */ new Set();
|
|
1918
|
+
this._handleLegacyDestinations();
|
|
1919
|
+
validateBackupConfig(this.driverName, this.driverConfig);
|
|
1130
1920
|
this._validateConfiguration();
|
|
1131
1921
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1922
|
+
/**
|
|
1923
|
+
* Convert legacy destinations format to multi driver format
|
|
1924
|
+
*/
|
|
1925
|
+
_handleLegacyDestinations() {
|
|
1926
|
+
if (this.config.destinations && Array.isArray(this.config.destinations)) {
|
|
1927
|
+
this.driverName = "multi";
|
|
1928
|
+
this.driverConfig = {
|
|
1929
|
+
strategy: "all",
|
|
1930
|
+
destinations: this.config.destinations.map((dest) => {
|
|
1931
|
+
const { type, ...config } = dest;
|
|
1932
|
+
return {
|
|
1933
|
+
driver: type,
|
|
1934
|
+
config
|
|
1935
|
+
};
|
|
1936
|
+
})
|
|
1937
|
+
};
|
|
1938
|
+
this.config.destinations = null;
|
|
1939
|
+
if (this.config.verbose) {
|
|
1940
|
+
console.log("[BackupPlugin] Converted legacy destinations format to multi driver");
|
|
1139
1941
|
}
|
|
1140
1942
|
}
|
|
1943
|
+
}
|
|
1944
|
+
_validateConfiguration() {
|
|
1141
1945
|
if (this.config.encryption && (!this.config.encryption.key || !this.config.encryption.algorithm)) {
|
|
1142
1946
|
throw new Error("BackupPlugin: Encryption requires both key and algorithm");
|
|
1143
1947
|
}
|
|
1948
|
+
if (this.config.compression && !["none", "gzip", "brotli", "deflate"].includes(this.config.compression)) {
|
|
1949
|
+
throw new Error("BackupPlugin: Invalid compression type. Use: none, gzip, brotli, deflate");
|
|
1950
|
+
}
|
|
1144
1951
|
}
|
|
1145
|
-
async
|
|
1146
|
-
this.
|
|
1952
|
+
async onSetup() {
|
|
1953
|
+
this.driver = createBackupDriver(this.driverName, this.driverConfig);
|
|
1954
|
+
await this.driver.setup(this.database);
|
|
1955
|
+
await promises.mkdir(this.config.tempDir, { recursive: true });
|
|
1147
1956
|
await this._createBackupMetadataResource();
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1957
|
+
if (this.config.verbose) {
|
|
1958
|
+
const storageInfo = this.driver.getStorageInfo();
|
|
1959
|
+
console.log(`[BackupPlugin] Initialized with driver: ${storageInfo.type}`);
|
|
1151
1960
|
}
|
|
1152
1961
|
this.emit("initialized", {
|
|
1153
|
-
|
|
1154
|
-
|
|
1962
|
+
driver: this.driver.getType(),
|
|
1963
|
+
config: this.driver.getStorageInfo()
|
|
1155
1964
|
});
|
|
1156
1965
|
}
|
|
1157
1966
|
async _createBackupMetadataResource() {
|
|
@@ -1162,7 +1971,8 @@ class BackupPlugin extends Plugin {
|
|
|
1162
1971
|
type: "string|required",
|
|
1163
1972
|
timestamp: "number|required",
|
|
1164
1973
|
resources: "json|required",
|
|
1165
|
-
|
|
1974
|
+
driverInfo: "json|required",
|
|
1975
|
+
// Store driver info instead of destinations
|
|
1166
1976
|
size: "number|default:0",
|
|
1167
1977
|
compressed: "boolean|default:false",
|
|
1168
1978
|
encrypted: "boolean|default:false",
|
|
@@ -1173,88 +1983,64 @@ class BackupPlugin extends Plugin {
|
|
|
1173
1983
|
createdAt: "string|required"
|
|
1174
1984
|
},
|
|
1175
1985
|
behavior: "body-overflow",
|
|
1176
|
-
|
|
1177
|
-
byType: { fields: { type: "string" } },
|
|
1178
|
-
byDate: { fields: { createdAt: "string|maxlength:10" } }
|
|
1179
|
-
}
|
|
1986
|
+
timestamps: true
|
|
1180
1987
|
}));
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const [ok] = await tryFn(() => promises.mkdir(this.config.tempDir, { recursive: true }));
|
|
1184
|
-
}
|
|
1185
|
-
async _setupScheduledBackups() {
|
|
1186
|
-
if (this.config.verbose) {
|
|
1187
|
-
console.log("[BackupPlugin] Scheduled backups configured:", this.config.schedule);
|
|
1988
|
+
if (!ok && this.config.verbose) {
|
|
1989
|
+
console.log(`[BackupPlugin] Backup metadata resource '${this.config.backupMetadataResource}' already exists`);
|
|
1188
1990
|
}
|
|
1189
1991
|
}
|
|
1190
1992
|
/**
|
|
1191
|
-
*
|
|
1993
|
+
* Create a backup
|
|
1994
|
+
* @param {string} type - Backup type ('full' or 'incremental')
|
|
1995
|
+
* @param {Object} options - Backup options
|
|
1996
|
+
* @returns {Object} Backup result
|
|
1192
1997
|
*/
|
|
1193
1998
|
async backup(type = "full", options = {}) {
|
|
1194
|
-
const backupId =
|
|
1195
|
-
|
|
1196
|
-
throw new Error(`Backup ${backupId} already in progress`);
|
|
1197
|
-
}
|
|
1198
|
-
this.activeBackups.add(backupId);
|
|
1999
|
+
const backupId = this._generateBackupId(type);
|
|
2000
|
+
const startTime = Date.now();
|
|
1199
2001
|
try {
|
|
1200
|
-
|
|
2002
|
+
this.activeBackups.add(backupId);
|
|
1201
2003
|
if (this.config.onBackupStart) {
|
|
1202
|
-
await this._executeHook(this.config.onBackupStart, type, { backupId
|
|
2004
|
+
await this._executeHook(this.config.onBackupStart, type, { backupId });
|
|
1203
2005
|
}
|
|
1204
2006
|
this.emit("backup_start", { id: backupId, type });
|
|
1205
2007
|
const metadata = await this._createBackupMetadata(backupId, type);
|
|
1206
|
-
const resources = await this._getResourcesToBackup();
|
|
1207
2008
|
const tempBackupDir = path.join(this.config.tempDir, backupId);
|
|
1208
2009
|
await promises.mkdir(tempBackupDir, { recursive: true });
|
|
1209
|
-
let totalSize = 0;
|
|
1210
|
-
const resourceFiles = /* @__PURE__ */ new Map();
|
|
1211
2010
|
try {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
const stats = await promises.stat(filePath);
|
|
1217
|
-
totalSize += stats.size;
|
|
1218
|
-
resourceFiles.set(resourceName, { path: filePath, size: stats.size });
|
|
1219
|
-
}
|
|
1220
|
-
const manifest = {
|
|
1221
|
-
id: backupId,
|
|
1222
|
-
type,
|
|
1223
|
-
timestamp: Date.now(),
|
|
1224
|
-
resources: Array.from(resourceFiles.keys()),
|
|
1225
|
-
totalSize,
|
|
1226
|
-
compression: this.config.compression,
|
|
1227
|
-
encryption: !!this.config.encryption
|
|
1228
|
-
};
|
|
1229
|
-
const manifestPath = path.join(tempBackupDir, "manifest.json");
|
|
1230
|
-
await promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
1231
|
-
let finalPath = tempBackupDir;
|
|
1232
|
-
if (this.config.compression !== "none") {
|
|
1233
|
-
finalPath = await this._compressBackup(tempBackupDir, backupId);
|
|
1234
|
-
}
|
|
1235
|
-
if (this.config.encryption) {
|
|
1236
|
-
finalPath = await this._encryptBackup(finalPath, backupId);
|
|
2011
|
+
const manifest = await this._createBackupManifest(type, options);
|
|
2012
|
+
const exportedFiles = await this._exportResources(manifest.resources, tempBackupDir, type);
|
|
2013
|
+
if (exportedFiles.length === 0) {
|
|
2014
|
+
throw new Error("No resources were exported for backup");
|
|
1237
2015
|
}
|
|
1238
|
-
let
|
|
1239
|
-
|
|
1240
|
-
|
|
2016
|
+
let finalPath;
|
|
2017
|
+
let totalSize = 0;
|
|
2018
|
+
if (this.config.compression !== "none") {
|
|
2019
|
+
finalPath = path.join(tempBackupDir, `${backupId}.tar.gz`);
|
|
2020
|
+
totalSize = await this._createCompressedArchive(exportedFiles, finalPath);
|
|
1241
2021
|
} else {
|
|
1242
|
-
|
|
2022
|
+
finalPath = exportedFiles[0];
|
|
2023
|
+
const [statOk, , stats] = await tryFn(() => promises.stat(finalPath));
|
|
2024
|
+
totalSize = statOk ? stats.size : 0;
|
|
1243
2025
|
}
|
|
1244
|
-
const
|
|
2026
|
+
const checksum = await this._generateChecksum(finalPath);
|
|
2027
|
+
const uploadResult = await this.driver.upload(finalPath, backupId, manifest);
|
|
1245
2028
|
if (this.config.verification) {
|
|
1246
|
-
await this.
|
|
2029
|
+
const isValid = await this.driver.verify(backupId, checksum, uploadResult);
|
|
2030
|
+
if (!isValid) {
|
|
2031
|
+
throw new Error("Backup verification failed");
|
|
2032
|
+
}
|
|
1247
2033
|
}
|
|
1248
2034
|
const duration = Date.now() - startTime;
|
|
1249
|
-
await this._updateBackupMetadata(
|
|
2035
|
+
await this._updateBackupMetadata(backupId, {
|
|
1250
2036
|
status: "completed",
|
|
1251
2037
|
size: totalSize,
|
|
1252
2038
|
checksum,
|
|
1253
|
-
|
|
2039
|
+
driverInfo: uploadResult,
|
|
1254
2040
|
duration
|
|
1255
2041
|
});
|
|
1256
2042
|
if (this.config.onBackupComplete) {
|
|
1257
|
-
const stats = { backupId, type, size: totalSize, duration,
|
|
2043
|
+
const stats = { backupId, type, size: totalSize, duration, driverInfo: uploadResult };
|
|
1258
2044
|
await this._executeHook(this.config.onBackupComplete, type, stats);
|
|
1259
2045
|
}
|
|
1260
2046
|
this.emit("backup_complete", {
|
|
@@ -1262,7 +2048,7 @@ class BackupPlugin extends Plugin {
|
|
|
1262
2048
|
type,
|
|
1263
2049
|
size: totalSize,
|
|
1264
2050
|
duration,
|
|
1265
|
-
|
|
2051
|
+
driverInfo: uploadResult
|
|
1266
2052
|
});
|
|
1267
2053
|
await this._cleanupOldBackups();
|
|
1268
2054
|
return {
|
|
@@ -1270,504 +2056,247 @@ class BackupPlugin extends Plugin {
|
|
|
1270
2056
|
type,
|
|
1271
2057
|
size: totalSize,
|
|
1272
2058
|
duration,
|
|
1273
|
-
checksum,
|
|
1274
|
-
|
|
1275
|
-
};
|
|
1276
|
-
} finally {
|
|
1277
|
-
await this._cleanupTempFiles(tempBackupDir);
|
|
1278
|
-
}
|
|
1279
|
-
} catch (error) {
|
|
1280
|
-
if (this.config.onBackupError) {
|
|
1281
|
-
await this._executeHook(this.config.onBackupError, type, { backupId, error });
|
|
1282
|
-
}
|
|
1283
|
-
this.emit("backup_error", { id: backupId, type, error: error.message });
|
|
1284
|
-
const [metadataOk] = await tryFn(
|
|
1285
|
-
() => this.database.resource(this.config.backupMetadataResource).update(backupId, { status: "failed", error: error.message })
|
|
1286
|
-
);
|
|
1287
|
-
throw error;
|
|
1288
|
-
} finally {
|
|
1289
|
-
this.activeBackups.delete(backupId);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
async _createBackupMetadata(backupId, type) {
|
|
1293
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1294
|
-
const metadata = {
|
|
1295
|
-
id: backupId,
|
|
1296
|
-
type,
|
|
1297
|
-
timestamp: Date.now(),
|
|
1298
|
-
resources: [],
|
|
1299
|
-
destinations: [],
|
|
1300
|
-
size: 0,
|
|
1301
|
-
status: "in_progress",
|
|
1302
|
-
compressed: this.config.compression !== "none",
|
|
1303
|
-
encrypted: !!this.config.encryption,
|
|
1304
|
-
checksum: null,
|
|
1305
|
-
error: null,
|
|
1306
|
-
duration: 0,
|
|
1307
|
-
createdAt: now.slice(0, 10)
|
|
1308
|
-
};
|
|
1309
|
-
await this.database.resource(this.config.backupMetadataResource).insert(metadata);
|
|
1310
|
-
return metadata;
|
|
1311
|
-
}
|
|
1312
|
-
async _updateBackupMetadata(backupId, updates) {
|
|
1313
|
-
const [ok] = await tryFn(
|
|
1314
|
-
() => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
|
|
1315
|
-
);
|
|
1316
|
-
}
|
|
1317
|
-
async _getResourcesToBackup() {
|
|
1318
|
-
const allResources = Object.keys(this.database.resources);
|
|
1319
|
-
let resources = allResources;
|
|
1320
|
-
if (this.config.include && this.config.include.length > 0) {
|
|
1321
|
-
resources = resources.filter((name) => this.config.include.includes(name));
|
|
1322
|
-
}
|
|
1323
|
-
if (this.config.exclude && this.config.exclude.length > 0) {
|
|
1324
|
-
resources = resources.filter((name) => {
|
|
1325
|
-
return !this.config.exclude.some((pattern) => {
|
|
1326
|
-
if (pattern.includes("*")) {
|
|
1327
|
-
const regex = new RegExp(pattern.replace(/\*/g, ".*"));
|
|
1328
|
-
return regex.test(name);
|
|
1329
|
-
}
|
|
1330
|
-
return name === pattern;
|
|
1331
|
-
});
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
resources = resources.filter((name) => name !== this.config.backupMetadataResource);
|
|
1335
|
-
return resources;
|
|
1336
|
-
}
|
|
1337
|
-
async _backupResource(resourceName, type) {
|
|
1338
|
-
const resource = this.database.resources[resourceName];
|
|
1339
|
-
if (!resource) {
|
|
1340
|
-
throw new Error(`Resource '${resourceName}' not found`);
|
|
1341
|
-
}
|
|
1342
|
-
if (type === "full") {
|
|
1343
|
-
const [ok, err, data] = await tryFn(() => resource.list({ limit: 999999 }));
|
|
1344
|
-
if (!ok) throw err;
|
|
1345
|
-
return {
|
|
1346
|
-
resource: resourceName,
|
|
1347
|
-
type: "full",
|
|
1348
|
-
data,
|
|
1349
|
-
count: data.length,
|
|
1350
|
-
config: resource.config
|
|
1351
|
-
};
|
|
1352
|
-
}
|
|
1353
|
-
if (type === "incremental") {
|
|
1354
|
-
const lastBackup = await this._getLastBackup("incremental");
|
|
1355
|
-
const since = lastBackup ? lastBackup.timestamp : 0;
|
|
1356
|
-
const [ok, err, data] = await tryFn(() => resource.list({ limit: 999999 }));
|
|
1357
|
-
if (!ok) throw err;
|
|
1358
|
-
return {
|
|
1359
|
-
resource: resourceName,
|
|
1360
|
-
type: "incremental",
|
|
1361
|
-
data,
|
|
1362
|
-
count: data.length,
|
|
1363
|
-
since,
|
|
1364
|
-
config: resource.config
|
|
1365
|
-
};
|
|
1366
|
-
}
|
|
1367
|
-
throw new Error(`Backup type '${type}' not supported`);
|
|
1368
|
-
}
|
|
1369
|
-
async _getLastBackup(type) {
|
|
1370
|
-
const [ok, err, backups] = await tryFn(
|
|
1371
|
-
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
1372
|
-
where: { type, status: "completed" },
|
|
1373
|
-
orderBy: { timestamp: "desc" },
|
|
1374
|
-
limit: 1
|
|
1375
|
-
})
|
|
1376
|
-
);
|
|
1377
|
-
return ok && backups.length > 0 ? backups[0] : null;
|
|
1378
|
-
}
|
|
1379
|
-
async _compressBackup(backupDir, backupId) {
|
|
1380
|
-
const compressedPath = `${backupDir}.tar.gz`;
|
|
1381
|
-
try {
|
|
1382
|
-
const files = await this._getDirectoryFiles(backupDir);
|
|
1383
|
-
const backupData = {};
|
|
1384
|
-
for (const file of files) {
|
|
1385
|
-
const filePath = path.join(backupDir, file);
|
|
1386
|
-
const content = await promises.readFile(filePath, "utf8");
|
|
1387
|
-
backupData[file] = content;
|
|
1388
|
-
}
|
|
1389
|
-
const serialized = JSON.stringify(backupData);
|
|
1390
|
-
const originalSize = Buffer.byteLength(serialized, "utf8");
|
|
1391
|
-
let compressedBuffer;
|
|
1392
|
-
let compressionType = this.config.compression;
|
|
1393
|
-
switch (this.config.compression) {
|
|
1394
|
-
case "gzip":
|
|
1395
|
-
compressedBuffer = zlib.gzipSync(Buffer.from(serialized, "utf8"));
|
|
1396
|
-
break;
|
|
1397
|
-
case "brotli":
|
|
1398
|
-
compressedBuffer = zlib.brotliCompressSync(Buffer.from(serialized, "utf8"));
|
|
1399
|
-
break;
|
|
1400
|
-
case "deflate":
|
|
1401
|
-
compressedBuffer = zlib.deflateSync(Buffer.from(serialized, "utf8"));
|
|
1402
|
-
break;
|
|
1403
|
-
case "none":
|
|
1404
|
-
compressedBuffer = Buffer.from(serialized, "utf8");
|
|
1405
|
-
compressionType = "none";
|
|
1406
|
-
break;
|
|
1407
|
-
default:
|
|
1408
|
-
throw new Error(`Unsupported compression type: ${this.config.compression}`);
|
|
1409
|
-
}
|
|
1410
|
-
const compressedData = this.config.compression !== "none" ? compressedBuffer.toString("base64") : serialized;
|
|
1411
|
-
await promises.writeFile(compressedPath, compressedData, "utf8");
|
|
1412
|
-
const compressedSize = Buffer.byteLength(compressedData, "utf8");
|
|
1413
|
-
const compressionRatio = (compressedSize / originalSize * 100).toFixed(2);
|
|
1414
|
-
if (this.config.verbose) {
|
|
1415
|
-
console.log(`[BackupPlugin] Compressed ${originalSize} bytes to ${compressedSize} bytes (${compressionRatio}% of original)`);
|
|
2059
|
+
checksum,
|
|
2060
|
+
driverInfo: uploadResult
|
|
2061
|
+
};
|
|
2062
|
+
} finally {
|
|
2063
|
+
await this._cleanupTempFiles(tempBackupDir);
|
|
1416
2064
|
}
|
|
1417
|
-
return compressedPath;
|
|
1418
2065
|
} catch (error) {
|
|
1419
|
-
|
|
2066
|
+
if (this.config.onBackupError) {
|
|
2067
|
+
await this._executeHook(this.config.onBackupError, type, { backupId, error });
|
|
2068
|
+
}
|
|
2069
|
+
await this._updateBackupMetadata(backupId, {
|
|
2070
|
+
status: "failed",
|
|
2071
|
+
error: error.message,
|
|
2072
|
+
duration: Date.now() - startTime
|
|
2073
|
+
});
|
|
2074
|
+
this.emit("backup_error", { id: backupId, type, error: error.message });
|
|
2075
|
+
throw error;
|
|
2076
|
+
} finally {
|
|
2077
|
+
this.activeBackups.delete(backupId);
|
|
1420
2078
|
}
|
|
1421
2079
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
const cipher = crypto.createCipher(algorithm, key);
|
|
1427
|
-
const input = fs.createReadStream(filePath);
|
|
1428
|
-
const output = fs.createWriteStream(encryptedPath);
|
|
1429
|
-
await promises$1.pipeline(input, cipher, output);
|
|
1430
|
-
await promises.unlink(filePath);
|
|
1431
|
-
return encryptedPath;
|
|
2080
|
+
_generateBackupId(type) {
|
|
2081
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2082
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
2083
|
+
return `${type}-${timestamp}-${random}`;
|
|
1432
2084
|
}
|
|
1433
|
-
async
|
|
1434
|
-
const
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2085
|
+
async _createBackupMetadata(backupId, type) {
|
|
2086
|
+
const now = /* @__PURE__ */ new Date();
|
|
2087
|
+
const metadata = {
|
|
2088
|
+
id: backupId,
|
|
2089
|
+
type,
|
|
2090
|
+
timestamp: Date.now(),
|
|
2091
|
+
resources: [],
|
|
2092
|
+
driverInfo: {},
|
|
2093
|
+
size: 0,
|
|
2094
|
+
status: "in_progress",
|
|
2095
|
+
compressed: this.config.compression !== "none",
|
|
2096
|
+
encrypted: !!this.config.encryption,
|
|
2097
|
+
checksum: null,
|
|
2098
|
+
error: null,
|
|
2099
|
+
duration: 0,
|
|
2100
|
+
createdAt: now.toISOString().slice(0, 10)
|
|
2101
|
+
};
|
|
2102
|
+
const [ok] = await tryFn(
|
|
2103
|
+
() => this.database.resource(this.config.backupMetadataResource).insert(metadata)
|
|
2104
|
+
);
|
|
2105
|
+
return metadata;
|
|
1441
2106
|
}
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
2107
|
+
async _updateBackupMetadata(backupId, updates) {
|
|
2108
|
+
const [ok] = await tryFn(
|
|
2109
|
+
() => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
|
|
2110
|
+
);
|
|
1446
2111
|
}
|
|
1447
|
-
async
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
const srcPath = path.join(src, entry.name);
|
|
1452
|
-
const destPath = path.join(dest, entry.name);
|
|
1453
|
-
if (entry.isDirectory()) {
|
|
1454
|
-
await this._copyDirectory(srcPath, destPath);
|
|
1455
|
-
} else {
|
|
1456
|
-
const input = fs.createReadStream(srcPath);
|
|
1457
|
-
const output = fs.createWriteStream(destPath);
|
|
1458
|
-
await promises$1.pipeline(input, output);
|
|
1459
|
-
}
|
|
2112
|
+
async _createBackupManifest(type, options) {
|
|
2113
|
+
let resourcesToBackup = options.resources || (this.config.include ? this.config.include : await this.database.listResources());
|
|
2114
|
+
if (Array.isArray(resourcesToBackup) && resourcesToBackup.length > 0 && typeof resourcesToBackup[0] === "object") {
|
|
2115
|
+
resourcesToBackup = resourcesToBackup.map((resource) => resource.name || resource);
|
|
1460
2116
|
}
|
|
2117
|
+
const filteredResources = resourcesToBackup.filter(
|
|
2118
|
+
(name) => !this.config.exclude.includes(name)
|
|
2119
|
+
);
|
|
2120
|
+
return {
|
|
2121
|
+
type,
|
|
2122
|
+
timestamp: Date.now(),
|
|
2123
|
+
resources: filteredResources,
|
|
2124
|
+
compression: this.config.compression,
|
|
2125
|
+
encrypted: !!this.config.encryption,
|
|
2126
|
+
s3db_version: this.database.constructor.version || "unknown"
|
|
2127
|
+
};
|
|
1461
2128
|
}
|
|
1462
|
-
async
|
|
1463
|
-
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
} else {
|
|
1470
|
-
const stats = await promises.stat(entryPath);
|
|
1471
|
-
totalSize += stats.size;
|
|
2129
|
+
async _exportResources(resourceNames, tempDir, type) {
|
|
2130
|
+
const exportedFiles = [];
|
|
2131
|
+
for (const resourceName of resourceNames) {
|
|
2132
|
+
const resource = this.database.resources[resourceName];
|
|
2133
|
+
if (!resource) {
|
|
2134
|
+
console.warn(`[BackupPlugin] Resource '${resourceName}' not found, skipping`);
|
|
2135
|
+
continue;
|
|
1472
2136
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const [ok, err, result] = await tryFn(
|
|
1481
|
-
() => this._uploadToDestination(filePath, backupId, manifest, destination)
|
|
1482
|
-
);
|
|
1483
|
-
if (ok) {
|
|
1484
|
-
results.push({ ...destination, ...result, status: "success" });
|
|
1485
|
-
hasSuccess = true;
|
|
2137
|
+
const exportPath = path.join(tempDir, `${resourceName}.json`);
|
|
2138
|
+
let records;
|
|
2139
|
+
if (type === "incremental") {
|
|
2140
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1e3);
|
|
2141
|
+
records = await resource.list({
|
|
2142
|
+
filter: { updatedAt: { ">": yesterday.toISOString() } }
|
|
2143
|
+
});
|
|
1486
2144
|
} else {
|
|
1487
|
-
|
|
1488
|
-
if (this.config.verbose) {
|
|
1489
|
-
console.warn(`[BackupPlugin] Upload to ${destination.type} failed:`, err.message);
|
|
1490
|
-
}
|
|
2145
|
+
records = await resource.list();
|
|
1491
2146
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
async _uploadToDestination(filePath, backupId, manifest, destination) {
|
|
1500
|
-
if (destination.type === "filesystem") {
|
|
1501
|
-
return this._uploadToFilesystem(filePath, backupId, destination);
|
|
1502
|
-
}
|
|
1503
|
-
if (destination.type === "s3") {
|
|
1504
|
-
return this._uploadToS3(filePath, backupId, destination);
|
|
1505
|
-
}
|
|
1506
|
-
throw new Error(`Destination type '${destination.type}' not supported`);
|
|
1507
|
-
}
|
|
1508
|
-
async _uploadToFilesystem(filePath, backupId, destination) {
|
|
1509
|
-
const destDir = destination.path.replace("{date}", (/* @__PURE__ */ new Date()).toISOString().slice(0, 10));
|
|
1510
|
-
await promises.mkdir(destDir, { recursive: true });
|
|
1511
|
-
const stats = await promises.stat(filePath);
|
|
1512
|
-
if (stats.isDirectory()) {
|
|
1513
|
-
const destPath = path.join(destDir, backupId);
|
|
1514
|
-
await this._copyDirectory(filePath, destPath);
|
|
1515
|
-
const dirStats = await this._getDirectorySize(destPath);
|
|
1516
|
-
return {
|
|
1517
|
-
path: destPath,
|
|
1518
|
-
size: dirStats,
|
|
1519
|
-
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1520
|
-
};
|
|
1521
|
-
} else {
|
|
1522
|
-
const fileName = path.basename(filePath);
|
|
1523
|
-
const destPath = path.join(destDir, fileName);
|
|
1524
|
-
const input = fs.createReadStream(filePath);
|
|
1525
|
-
const output = fs.createWriteStream(destPath);
|
|
1526
|
-
await promises$1.pipeline(input, output);
|
|
1527
|
-
const fileStats = await promises.stat(destPath);
|
|
1528
|
-
return {
|
|
1529
|
-
path: destPath,
|
|
1530
|
-
size: fileStats.size,
|
|
1531
|
-
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2147
|
+
const exportData = {
|
|
2148
|
+
resourceName,
|
|
2149
|
+
definition: resource.config,
|
|
2150
|
+
records,
|
|
2151
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2152
|
+
type
|
|
1532
2153
|
};
|
|
2154
|
+
await promises.writeFile(exportPath, JSON.stringify(exportData, null, 2));
|
|
2155
|
+
exportedFiles.push(exportPath);
|
|
2156
|
+
if (this.config.verbose) {
|
|
2157
|
+
console.log(`[BackupPlugin] Exported ${records.length} records from '${resourceName}'`);
|
|
2158
|
+
}
|
|
1533
2159
|
}
|
|
2160
|
+
return exportedFiles;
|
|
1534
2161
|
}
|
|
1535
|
-
async
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
async _cleanupOldBackups() {
|
|
1550
|
-
const retention = this.config.retention;
|
|
1551
|
-
const now = /* @__PURE__ */ new Date();
|
|
1552
|
-
const [ok, err, allBackups] = await tryFn(
|
|
1553
|
-
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
1554
|
-
where: { status: "completed" },
|
|
1555
|
-
orderBy: { timestamp: "desc" }
|
|
1556
|
-
})
|
|
1557
|
-
);
|
|
1558
|
-
if (!ok) return;
|
|
1559
|
-
const toDelete = [];
|
|
1560
|
-
const groups = {
|
|
1561
|
-
daily: [],
|
|
1562
|
-
weekly: [],
|
|
1563
|
-
monthly: [],
|
|
1564
|
-
yearly: []
|
|
1565
|
-
};
|
|
1566
|
-
for (const backup of allBackups) {
|
|
1567
|
-
const backupDate = new Date(backup.timestamp);
|
|
1568
|
-
const age = Math.floor((now - backupDate) / (1e3 * 60 * 60 * 24));
|
|
1569
|
-
if (age < 7) groups.daily.push(backup);
|
|
1570
|
-
else if (age < 30) groups.weekly.push(backup);
|
|
1571
|
-
else if (age < 365) groups.monthly.push(backup);
|
|
1572
|
-
else groups.yearly.push(backup);
|
|
1573
|
-
}
|
|
1574
|
-
if (groups.daily.length > retention.daily) {
|
|
1575
|
-
toDelete.push(...groups.daily.slice(retention.daily));
|
|
1576
|
-
}
|
|
1577
|
-
if (groups.weekly.length > retention.weekly) {
|
|
1578
|
-
toDelete.push(...groups.weekly.slice(retention.weekly));
|
|
1579
|
-
}
|
|
1580
|
-
if (groups.monthly.length > retention.monthly) {
|
|
1581
|
-
toDelete.push(...groups.monthly.slice(retention.monthly));
|
|
1582
|
-
}
|
|
1583
|
-
if (groups.yearly.length > retention.yearly) {
|
|
1584
|
-
toDelete.push(...groups.yearly.slice(retention.yearly));
|
|
1585
|
-
}
|
|
1586
|
-
for (const backup of toDelete) {
|
|
1587
|
-
await this._deleteBackup(backup);
|
|
1588
|
-
}
|
|
1589
|
-
if (toDelete.length > 0) {
|
|
1590
|
-
this.emit("cleanup_complete", { deleted: toDelete.length });
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
async _deleteBackup(backup) {
|
|
1594
|
-
for (const dest of backup.destinations || []) {
|
|
1595
|
-
const [ok2] = await tryFn(() => this._deleteFromDestination(backup, dest));
|
|
1596
|
-
}
|
|
1597
|
-
const [ok] = await tryFn(
|
|
1598
|
-
() => this.database.resource(this.config.backupMetadataResource).delete(backup.id)
|
|
2162
|
+
async _createCompressedArchive(files, targetPath) {
|
|
2163
|
+
const output = fs.createWriteStream(targetPath);
|
|
2164
|
+
const gzip = zlib.createGzip({ level: 6 });
|
|
2165
|
+
let totalSize = 0;
|
|
2166
|
+
await promises$1.pipeline(
|
|
2167
|
+
async function* () {
|
|
2168
|
+
for (const filePath of files) {
|
|
2169
|
+
const content = await promises.readFile(filePath);
|
|
2170
|
+
totalSize += content.length;
|
|
2171
|
+
yield content;
|
|
2172
|
+
}
|
|
2173
|
+
},
|
|
2174
|
+
gzip,
|
|
2175
|
+
output
|
|
1599
2176
|
);
|
|
2177
|
+
const [statOk, , stats] = await tryFn(() => promises.stat(targetPath));
|
|
2178
|
+
return statOk ? stats.size : totalSize;
|
|
1600
2179
|
}
|
|
1601
|
-
async
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
2180
|
+
async _generateChecksum(filePath) {
|
|
2181
|
+
const hash = crypto.createHash("sha256");
|
|
2182
|
+
const stream = fs.createReadStream(filePath);
|
|
2183
|
+
await promises$1.pipeline(stream, hash);
|
|
2184
|
+
return hash.digest("hex");
|
|
1605
2185
|
}
|
|
1606
2186
|
async _cleanupTempFiles(tempDir) {
|
|
1607
|
-
const [ok] = await tryFn(
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
await promises.unlink(file);
|
|
1611
|
-
}
|
|
1612
|
-
});
|
|
1613
|
-
}
|
|
1614
|
-
async _getDirectoryFiles(dir) {
|
|
1615
|
-
return [];
|
|
1616
|
-
}
|
|
1617
|
-
async _executeHook(hook, ...args) {
|
|
1618
|
-
if (typeof hook === "function") {
|
|
1619
|
-
const [ok, err] = await tryFn(() => hook(...args));
|
|
1620
|
-
if (!ok && this.config.verbose) {
|
|
1621
|
-
console.warn("[BackupPlugin] Hook execution failed:", err.message);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
2187
|
+
const [ok] = await tryFn(
|
|
2188
|
+
() => import('fs/promises').then((fs) => fs.rm(tempDir, { recursive: true, force: true }))
|
|
2189
|
+
);
|
|
1624
2190
|
}
|
|
1625
2191
|
/**
|
|
1626
2192
|
* Restore from backup
|
|
2193
|
+
* @param {string} backupId - Backup identifier
|
|
2194
|
+
* @param {Object} options - Restore options
|
|
2195
|
+
* @returns {Object} Restore result
|
|
1627
2196
|
*/
|
|
1628
2197
|
async restore(backupId, options = {}) {
|
|
1629
|
-
const { overwrite = false, resources = null } = options;
|
|
1630
|
-
const [ok, err, backup] = await tryFn(
|
|
1631
|
-
() => this.database.resource(this.config.backupMetadataResource).get(backupId)
|
|
1632
|
-
);
|
|
1633
|
-
if (!ok || !backup) {
|
|
1634
|
-
throw new Error(`Backup '${backupId}' not found`);
|
|
1635
|
-
}
|
|
1636
|
-
if (backup.status !== "completed") {
|
|
1637
|
-
throw new Error(`Backup '${backupId}' is not in completed status`);
|
|
1638
|
-
}
|
|
1639
|
-
this.emit("restore_start", { backupId });
|
|
1640
|
-
const tempDir = path.join(this.config.tempDir, `restore_${backupId}`);
|
|
1641
|
-
await promises.mkdir(tempDir, { recursive: true });
|
|
1642
|
-
try {
|
|
1643
|
-
await this._downloadBackup(backup, tempDir);
|
|
1644
|
-
if (backup.encrypted) {
|
|
1645
|
-
await this._decryptBackup(tempDir);
|
|
1646
|
-
}
|
|
1647
|
-
if (backup.compressed) {
|
|
1648
|
-
await this._decompressBackup(tempDir);
|
|
1649
|
-
}
|
|
1650
|
-
const manifestPath = path.join(tempDir, "manifest.json");
|
|
1651
|
-
const manifest = JSON.parse(await promises.readFile(manifestPath, "utf-8"));
|
|
1652
|
-
const resourcesToRestore = resources || manifest.resources;
|
|
1653
|
-
const restored = [];
|
|
1654
|
-
for (const resourceName of resourcesToRestore) {
|
|
1655
|
-
const resourcePath = path.join(tempDir, `${resourceName}.json`);
|
|
1656
|
-
const resourceData = JSON.parse(await promises.readFile(resourcePath, "utf-8"));
|
|
1657
|
-
await this._restoreResource(resourceName, resourceData, overwrite);
|
|
1658
|
-
restored.push(resourceName);
|
|
1659
|
-
}
|
|
1660
|
-
this.emit("restore_complete", { backupId, restored });
|
|
1661
|
-
return { backupId, restored };
|
|
1662
|
-
} finally {
|
|
1663
|
-
await this._cleanupTempFiles(tempDir);
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
async _downloadBackup(backup, tempDir) {
|
|
1667
|
-
for (const dest of backup.destinations) {
|
|
1668
|
-
const [ok] = await tryFn(() => this._downloadFromDestination(backup, dest, tempDir));
|
|
1669
|
-
if (ok) return;
|
|
1670
|
-
}
|
|
1671
|
-
throw new Error("Failed to download backup from any destination");
|
|
1672
|
-
}
|
|
1673
|
-
async _downloadFromDestination(backup, destination, tempDir) {
|
|
1674
|
-
if (this.config.verbose) {
|
|
1675
|
-
console.log(`[BackupPlugin] Downloading backup ${backup.id} from ${destination.type}`);
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
async _decryptBackup(tempDir) {
|
|
1679
|
-
}
|
|
1680
|
-
async _decompressBackup(tempDir) {
|
|
1681
2198
|
try {
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
console.log(`[BackupPlugin] Decompressed backup with ${Object.keys(backupData).length} files`);
|
|
2199
|
+
if (this.config.onRestoreStart) {
|
|
2200
|
+
await this._executeHook(this.config.onRestoreStart, backupId, options);
|
|
2201
|
+
}
|
|
2202
|
+
this.emit("restore_start", { id: backupId, options });
|
|
2203
|
+
const backup = await this.getBackupStatus(backupId);
|
|
2204
|
+
if (!backup) {
|
|
2205
|
+
throw new Error(`Backup '${backupId}' not found`);
|
|
2206
|
+
}
|
|
2207
|
+
if (backup.status !== "completed") {
|
|
2208
|
+
throw new Error(`Backup '${backupId}' is not in completed status`);
|
|
2209
|
+
}
|
|
2210
|
+
const tempRestoreDir = path.join(this.config.tempDir, `restore-${backupId}`);
|
|
2211
|
+
await promises.mkdir(tempRestoreDir, { recursive: true });
|
|
2212
|
+
try {
|
|
2213
|
+
const downloadPath = path.join(tempRestoreDir, `${backupId}.backup`);
|
|
2214
|
+
await this.driver.download(backupId, downloadPath, backup.driverInfo);
|
|
2215
|
+
if (this.config.verification && backup.checksum) {
|
|
2216
|
+
const actualChecksum = await this._generateChecksum(downloadPath);
|
|
2217
|
+
if (actualChecksum !== backup.checksum) {
|
|
2218
|
+
throw new Error("Backup verification failed during restore");
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
const restoredResources = await this._restoreFromBackup(downloadPath, options);
|
|
2222
|
+
if (this.config.onRestoreComplete) {
|
|
2223
|
+
await this._executeHook(this.config.onRestoreComplete, backupId, { restored: restoredResources });
|
|
2224
|
+
}
|
|
2225
|
+
this.emit("restore_complete", {
|
|
2226
|
+
id: backupId,
|
|
2227
|
+
restored: restoredResources
|
|
2228
|
+
});
|
|
2229
|
+
return {
|
|
2230
|
+
backupId,
|
|
2231
|
+
restored: restoredResources
|
|
2232
|
+
};
|
|
2233
|
+
} finally {
|
|
2234
|
+
await this._cleanupTempFiles(tempRestoreDir);
|
|
1719
2235
|
}
|
|
1720
2236
|
} catch (error) {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
}
|
|
1724
|
-
async _restoreResource(resourceName, resourceData, overwrite) {
|
|
1725
|
-
const resource = this.database.resources[resourceName];
|
|
1726
|
-
if (!resource) {
|
|
1727
|
-
await this.database.createResource(resourceData.config);
|
|
1728
|
-
}
|
|
1729
|
-
for (const record of resourceData.data) {
|
|
1730
|
-
if (overwrite) {
|
|
1731
|
-
await resource.upsert(record.id, record);
|
|
1732
|
-
} else {
|
|
1733
|
-
const [ok] = await tryFn(() => resource.insert(record));
|
|
2237
|
+
if (this.config.onRestoreError) {
|
|
2238
|
+
await this._executeHook(this.config.onRestoreError, backupId, { error });
|
|
1734
2239
|
}
|
|
2240
|
+
this.emit("restore_error", { id: backupId, error: error.message });
|
|
2241
|
+
throw error;
|
|
1735
2242
|
}
|
|
1736
2243
|
}
|
|
2244
|
+
async _restoreFromBackup(backupPath, options) {
|
|
2245
|
+
const restoredResources = [];
|
|
2246
|
+
return restoredResources;
|
|
2247
|
+
}
|
|
1737
2248
|
/**
|
|
1738
2249
|
* List available backups
|
|
2250
|
+
* @param {Object} options - List options
|
|
2251
|
+
* @returns {Array} List of backups
|
|
1739
2252
|
*/
|
|
1740
2253
|
async listBackups(options = {}) {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
2254
|
+
try {
|
|
2255
|
+
const driverBackups = await this.driver.list(options);
|
|
2256
|
+
const [metaOk, , metadataRecords] = await tryFn(
|
|
2257
|
+
() => this.database.resource(this.config.backupMetadataResource).list({
|
|
2258
|
+
limit: options.limit || 50,
|
|
2259
|
+
sort: { timestamp: -1 }
|
|
2260
|
+
})
|
|
2261
|
+
);
|
|
2262
|
+
const metadataMap = /* @__PURE__ */ new Map();
|
|
2263
|
+
if (metaOk) {
|
|
2264
|
+
metadataRecords.forEach((record) => metadataMap.set(record.id, record));
|
|
2265
|
+
}
|
|
2266
|
+
const combinedBackups = driverBackups.map((backup) => ({
|
|
2267
|
+
...backup,
|
|
2268
|
+
...metadataMap.get(backup.id) || {}
|
|
2269
|
+
}));
|
|
2270
|
+
return combinedBackups;
|
|
2271
|
+
} catch (error) {
|
|
2272
|
+
if (this.config.verbose) {
|
|
2273
|
+
console.log(`[BackupPlugin] Error listing backups: ${error.message}`);
|
|
2274
|
+
}
|
|
2275
|
+
return [];
|
|
1756
2276
|
}
|
|
1757
|
-
return filteredBackups.slice(0, limit);
|
|
1758
2277
|
}
|
|
1759
2278
|
/**
|
|
1760
2279
|
* Get backup status
|
|
2280
|
+
* @param {string} backupId - Backup identifier
|
|
2281
|
+
* @returns {Object|null} Backup status
|
|
1761
2282
|
*/
|
|
1762
2283
|
async getBackupStatus(backupId) {
|
|
1763
|
-
const [ok,
|
|
2284
|
+
const [ok, , backup] = await tryFn(
|
|
1764
2285
|
() => this.database.resource(this.config.backupMetadataResource).get(backupId)
|
|
1765
2286
|
);
|
|
1766
2287
|
return ok ? backup : null;
|
|
1767
2288
|
}
|
|
2289
|
+
async _cleanupOldBackups() {
|
|
2290
|
+
}
|
|
2291
|
+
async _executeHook(hook, ...args) {
|
|
2292
|
+
if (typeof hook === "function") {
|
|
2293
|
+
return await hook(...args);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
1768
2296
|
async start() {
|
|
1769
2297
|
if (this.config.verbose) {
|
|
1770
|
-
|
|
2298
|
+
const storageInfo = this.driver.getStorageInfo();
|
|
2299
|
+
console.log(`[BackupPlugin] Started with driver: ${storageInfo.type}`);
|
|
1771
2300
|
}
|
|
1772
2301
|
}
|
|
1773
2302
|
async stop() {
|
|
@@ -1775,10 +2304,15 @@ class BackupPlugin extends Plugin {
|
|
|
1775
2304
|
this.emit("backup_cancelled", { id: backupId });
|
|
1776
2305
|
}
|
|
1777
2306
|
this.activeBackups.clear();
|
|
2307
|
+
if (this.driver) {
|
|
2308
|
+
await this.driver.cleanup();
|
|
2309
|
+
}
|
|
1778
2310
|
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Cleanup plugin resources (alias for stop for backward compatibility)
|
|
2313
|
+
*/
|
|
1779
2314
|
async cleanup() {
|
|
1780
2315
|
await this.stop();
|
|
1781
|
-
this.removeAllListeners();
|
|
1782
2316
|
}
|
|
1783
2317
|
}
|
|
1784
2318
|
|
|
@@ -5736,6 +6270,42 @@ class Client extends EventEmitter {
|
|
|
5736
6270
|
}
|
|
5737
6271
|
}
|
|
5738
6272
|
|
|
6273
|
+
class AsyncEventEmitter extends EventEmitter {
|
|
6274
|
+
constructor() {
|
|
6275
|
+
super();
|
|
6276
|
+
this._asyncMode = true;
|
|
6277
|
+
}
|
|
6278
|
+
emit(event, ...args) {
|
|
6279
|
+
if (!this._asyncMode) {
|
|
6280
|
+
return super.emit(event, ...args);
|
|
6281
|
+
}
|
|
6282
|
+
const listeners = this.listeners(event);
|
|
6283
|
+
if (listeners.length === 0) {
|
|
6284
|
+
return false;
|
|
6285
|
+
}
|
|
6286
|
+
setImmediate(async () => {
|
|
6287
|
+
for (const listener of listeners) {
|
|
6288
|
+
try {
|
|
6289
|
+
await listener(...args);
|
|
6290
|
+
} catch (error) {
|
|
6291
|
+
if (event !== "error") {
|
|
6292
|
+
this.emit("error", error);
|
|
6293
|
+
} else {
|
|
6294
|
+
console.error("Error in error handler:", error);
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
}
|
|
6298
|
+
});
|
|
6299
|
+
return true;
|
|
6300
|
+
}
|
|
6301
|
+
emitSync(event, ...args) {
|
|
6302
|
+
return super.emit(event, ...args);
|
|
6303
|
+
}
|
|
6304
|
+
setAsyncMode(enabled) {
|
|
6305
|
+
this._asyncMode = enabled;
|
|
6306
|
+
}
|
|
6307
|
+
}
|
|
6308
|
+
|
|
5739
6309
|
async function secretHandler(actual, errors, schema) {
|
|
5740
6310
|
if (!this.passphrase) {
|
|
5741
6311
|
errors.push(new ValidationError("Missing configuration for secrets encryption.", {
|
|
@@ -6787,7 +7357,7 @@ function getBehavior(behaviorName) {
|
|
|
6787
7357
|
const AVAILABLE_BEHAVIORS = Object.keys(behaviors);
|
|
6788
7358
|
const DEFAULT_BEHAVIOR = "user-managed";
|
|
6789
7359
|
|
|
6790
|
-
class Resource extends
|
|
7360
|
+
class Resource extends AsyncEventEmitter {
|
|
6791
7361
|
/**
|
|
6792
7362
|
* Create a new Resource instance
|
|
6793
7363
|
* @param {Object} config - Resource configuration
|
|
@@ -6811,6 +7381,7 @@ class Resource extends EventEmitter {
|
|
|
6811
7381
|
* @param {number} [config.idSize=22] - Size for auto-generated IDs
|
|
6812
7382
|
* @param {boolean} [config.versioningEnabled=false] - Enable versioning for this resource
|
|
6813
7383
|
* @param {Object} [config.events={}] - Event listeners to automatically add
|
|
7384
|
+
* @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
|
|
6814
7385
|
* @example
|
|
6815
7386
|
* const users = new Resource({
|
|
6816
7387
|
* name: 'users',
|
|
@@ -6901,7 +7472,9 @@ ${errorDetails}`,
|
|
|
6901
7472
|
idGenerator: customIdGenerator,
|
|
6902
7473
|
idSize = 22,
|
|
6903
7474
|
versioningEnabled = false,
|
|
6904
|
-
events = {}
|
|
7475
|
+
events = {},
|
|
7476
|
+
asyncEvents = true,
|
|
7477
|
+
asyncPartitions = true
|
|
6905
7478
|
} = config;
|
|
6906
7479
|
this.name = name;
|
|
6907
7480
|
this.client = client;
|
|
@@ -6911,6 +7484,7 @@ ${errorDetails}`,
|
|
|
6911
7484
|
this.parallelism = parallelism;
|
|
6912
7485
|
this.passphrase = passphrase ?? "secret";
|
|
6913
7486
|
this.versioningEnabled = versioningEnabled;
|
|
7487
|
+
this.setAsyncMode(asyncEvents);
|
|
6914
7488
|
this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
|
|
6915
7489
|
if (typeof customIdGenerator === "number" && customIdGenerator > 0) {
|
|
6916
7490
|
this.idSize = customIdGenerator;
|
|
@@ -6927,7 +7501,9 @@ ${errorDetails}`,
|
|
|
6927
7501
|
timestamps,
|
|
6928
7502
|
partitions,
|
|
6929
7503
|
autoDecrypt,
|
|
6930
|
-
allNestedObjectsOptional
|
|
7504
|
+
allNestedObjectsOptional,
|
|
7505
|
+
asyncEvents,
|
|
7506
|
+
asyncPartitions
|
|
6931
7507
|
};
|
|
6932
7508
|
this.hooks = {
|
|
6933
7509
|
beforeInsert: [],
|
|
@@ -7430,9 +8006,31 @@ ${errorDetails}`,
|
|
|
7430
8006
|
throw errPut;
|
|
7431
8007
|
}
|
|
7432
8008
|
const insertedObject = await this.get(finalId);
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
8009
|
+
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
8010
|
+
setImmediate(() => {
|
|
8011
|
+
this.createPartitionReferences(insertedObject).catch((err) => {
|
|
8012
|
+
this.emit("partitionIndexError", {
|
|
8013
|
+
operation: "insert",
|
|
8014
|
+
id: finalId,
|
|
8015
|
+
error: err,
|
|
8016
|
+
message: err.message
|
|
8017
|
+
});
|
|
8018
|
+
});
|
|
8019
|
+
});
|
|
8020
|
+
const nonPartitionHooks = this.hooks.afterInsert.filter(
|
|
8021
|
+
(hook) => !hook.toString().includes("createPartitionReferences")
|
|
8022
|
+
);
|
|
8023
|
+
let finalResult = insertedObject;
|
|
8024
|
+
for (const hook of nonPartitionHooks) {
|
|
8025
|
+
finalResult = await hook(finalResult);
|
|
8026
|
+
}
|
|
8027
|
+
this.emit("insert", finalResult);
|
|
8028
|
+
return finalResult;
|
|
8029
|
+
} else {
|
|
8030
|
+
const finalResult = await this.executeHooks("afterInsert", insertedObject);
|
|
8031
|
+
this.emit("insert", finalResult);
|
|
8032
|
+
return finalResult;
|
|
8033
|
+
}
|
|
7436
8034
|
}
|
|
7437
8035
|
/**
|
|
7438
8036
|
* Retrieve a resource object by ID
|
|
@@ -7661,13 +8259,39 @@ ${errorDetails}`,
|
|
|
7661
8259
|
body: finalBody,
|
|
7662
8260
|
behavior: this.behavior
|
|
7663
8261
|
});
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
8262
|
+
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
8263
|
+
setImmediate(() => {
|
|
8264
|
+
this.handlePartitionReferenceUpdates(originalData, updatedData).catch((err2) => {
|
|
8265
|
+
this.emit("partitionIndexError", {
|
|
8266
|
+
operation: "update",
|
|
8267
|
+
id,
|
|
8268
|
+
error: err2,
|
|
8269
|
+
message: err2.message
|
|
8270
|
+
});
|
|
8271
|
+
});
|
|
8272
|
+
});
|
|
8273
|
+
const nonPartitionHooks = this.hooks.afterUpdate.filter(
|
|
8274
|
+
(hook) => !hook.toString().includes("handlePartitionReferenceUpdates")
|
|
8275
|
+
);
|
|
8276
|
+
let finalResult = updatedData;
|
|
8277
|
+
for (const hook of nonPartitionHooks) {
|
|
8278
|
+
finalResult = await hook(finalResult);
|
|
8279
|
+
}
|
|
8280
|
+
this.emit("update", {
|
|
8281
|
+
...updatedData,
|
|
8282
|
+
$before: { ...originalData },
|
|
8283
|
+
$after: { ...finalResult }
|
|
8284
|
+
});
|
|
8285
|
+
return finalResult;
|
|
8286
|
+
} else {
|
|
8287
|
+
const finalResult = await this.executeHooks("afterUpdate", updatedData);
|
|
8288
|
+
this.emit("update", {
|
|
8289
|
+
...updatedData,
|
|
8290
|
+
$before: { ...originalData },
|
|
8291
|
+
$after: { ...finalResult }
|
|
8292
|
+
});
|
|
8293
|
+
return finalResult;
|
|
8294
|
+
}
|
|
7671
8295
|
}
|
|
7672
8296
|
/**
|
|
7673
8297
|
* Delete a resource object by ID
|
|
@@ -7712,8 +8336,29 @@ ${errorDetails}`,
|
|
|
7712
8336
|
operation: "delete",
|
|
7713
8337
|
id
|
|
7714
8338
|
});
|
|
7715
|
-
|
|
7716
|
-
|
|
8339
|
+
if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
|
|
8340
|
+
setImmediate(() => {
|
|
8341
|
+
this.deletePartitionReferences(objectData).catch((err3) => {
|
|
8342
|
+
this.emit("partitionIndexError", {
|
|
8343
|
+
operation: "delete",
|
|
8344
|
+
id,
|
|
8345
|
+
error: err3,
|
|
8346
|
+
message: err3.message
|
|
8347
|
+
});
|
|
8348
|
+
});
|
|
8349
|
+
});
|
|
8350
|
+
const nonPartitionHooks = this.hooks.afterDelete.filter(
|
|
8351
|
+
(hook) => !hook.toString().includes("deletePartitionReferences")
|
|
8352
|
+
);
|
|
8353
|
+
let afterDeleteData = objectData;
|
|
8354
|
+
for (const hook of nonPartitionHooks) {
|
|
8355
|
+
afterDeleteData = await hook(afterDeleteData);
|
|
8356
|
+
}
|
|
8357
|
+
return response;
|
|
8358
|
+
} else {
|
|
8359
|
+
await this.executeHooks("afterDelete", objectData);
|
|
8360
|
+
return response;
|
|
8361
|
+
}
|
|
7717
8362
|
}
|
|
7718
8363
|
/**
|
|
7719
8364
|
* Insert or update a resource object (upsert operation)
|
|
@@ -8406,19 +9051,29 @@ ${errorDetails}`,
|
|
|
8406
9051
|
if (!partitions || Object.keys(partitions).length === 0) {
|
|
8407
9052
|
return;
|
|
8408
9053
|
}
|
|
8409
|
-
|
|
9054
|
+
const promises = Object.entries(partitions).map(async ([partitionName, partition]) => {
|
|
8410
9055
|
const partitionKey = this.getPartitionKey({ partitionName, id: data.id, data });
|
|
8411
9056
|
if (partitionKey) {
|
|
8412
9057
|
const partitionMetadata = {
|
|
8413
9058
|
_v: String(this.version)
|
|
8414
9059
|
};
|
|
8415
|
-
|
|
9060
|
+
return this.client.putObject({
|
|
8416
9061
|
key: partitionKey,
|
|
8417
9062
|
metadata: partitionMetadata,
|
|
8418
9063
|
body: "",
|
|
8419
9064
|
contentType: void 0
|
|
8420
9065
|
});
|
|
8421
9066
|
}
|
|
9067
|
+
return null;
|
|
9068
|
+
});
|
|
9069
|
+
const results = await Promise.allSettled(promises);
|
|
9070
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
9071
|
+
if (failures.length > 0) {
|
|
9072
|
+
this.emit("partitionIndexWarning", {
|
|
9073
|
+
operation: "create",
|
|
9074
|
+
id: data.id,
|
|
9075
|
+
failures: failures.map((f) => f.reason)
|
|
9076
|
+
});
|
|
8422
9077
|
}
|
|
8423
9078
|
}
|
|
8424
9079
|
/**
|
|
@@ -8519,26 +9174,28 @@ ${errorDetails}`,
|
|
|
8519
9174
|
if (!partitions || Object.keys(partitions).length === 0) {
|
|
8520
9175
|
return;
|
|
8521
9176
|
}
|
|
8522
|
-
|
|
9177
|
+
const updatePromises = Object.entries(partitions).map(async ([partitionName, partition]) => {
|
|
8523
9178
|
const [ok, err] = await tryFn(() => this.handlePartitionReferenceUpdate(partitionName, partition, oldData, newData));
|
|
8524
|
-
|
|
9179
|
+
if (!ok) {
|
|
9180
|
+
return { partitionName, error: err };
|
|
9181
|
+
}
|
|
9182
|
+
return { partitionName, success: true };
|
|
9183
|
+
});
|
|
9184
|
+
await Promise.allSettled(updatePromises);
|
|
8525
9185
|
const id = newData.id || oldData.id;
|
|
8526
|
-
|
|
9186
|
+
const cleanupPromises = Object.entries(partitions).map(async ([partitionName, partition]) => {
|
|
8527
9187
|
const prefix = `resource=${this.name}/partition=${partitionName}`;
|
|
8528
|
-
let allKeys = [];
|
|
8529
9188
|
const [okKeys, errKeys, keys] = await tryFn(() => this.client.getAllKeys({ prefix }));
|
|
8530
|
-
if (okKeys) {
|
|
8531
|
-
|
|
8532
|
-
} else {
|
|
8533
|
-
continue;
|
|
9189
|
+
if (!okKeys) {
|
|
9190
|
+
return;
|
|
8534
9191
|
}
|
|
8535
9192
|
const validKey = this.getPartitionKey({ partitionName, id, data: newData });
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
}
|
|
9193
|
+
const staleKeys = keys.filter((key) => key.endsWith(`/id=${id}`) && key !== validKey);
|
|
9194
|
+
if (staleKeys.length > 0) {
|
|
9195
|
+
const [okDel, errDel] = await tryFn(() => this.client.deleteObjects(staleKeys));
|
|
8540
9196
|
}
|
|
8541
|
-
}
|
|
9197
|
+
});
|
|
9198
|
+
await Promise.allSettled(cleanupPromises);
|
|
8542
9199
|
}
|
|
8543
9200
|
/**
|
|
8544
9201
|
* Handle partition reference update for a specific partition
|
|
@@ -8818,9 +9475,6 @@ ${errorDetails}`,
|
|
|
8818
9475
|
}
|
|
8819
9476
|
return filtered;
|
|
8820
9477
|
}
|
|
8821
|
-
emit(event, ...args) {
|
|
8822
|
-
return super.emit(event, ...args);
|
|
8823
|
-
}
|
|
8824
9478
|
async replace(id, attributes) {
|
|
8825
9479
|
await this.delete(id);
|
|
8826
9480
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -9046,7 +9700,7 @@ class Database extends EventEmitter {
|
|
|
9046
9700
|
this.id = idGenerator(7);
|
|
9047
9701
|
this.version = "1";
|
|
9048
9702
|
this.s3dbVersion = (() => {
|
|
9049
|
-
const [ok, err, version] = tryFn(() => true ? "9.2.
|
|
9703
|
+
const [ok, err, version] = tryFn(() => true ? "9.2.2" : "latest");
|
|
9050
9704
|
return ok ? version : "latest";
|
|
9051
9705
|
})();
|
|
9052
9706
|
this.resources = {};
|
|
@@ -9088,6 +9742,7 @@ class Database extends EventEmitter {
|
|
|
9088
9742
|
parallelism: this.parallelism,
|
|
9089
9743
|
connectionString
|
|
9090
9744
|
});
|
|
9745
|
+
this.connectionString = connectionString;
|
|
9091
9746
|
this.bucket = this.client.bucket;
|
|
9092
9747
|
this.keyPrefix = this.client.keyPrefix;
|
|
9093
9748
|
if (!this._exitListenerRegistered) {
|
|
@@ -9178,6 +9833,7 @@ class Database extends EventEmitter {
|
|
|
9178
9833
|
paranoid: versionData.paranoid !== void 0 ? versionData.paranoid : true,
|
|
9179
9834
|
allNestedObjectsOptional: versionData.allNestedObjectsOptional !== void 0 ? versionData.allNestedObjectsOptional : true,
|
|
9180
9835
|
autoDecrypt: versionData.autoDecrypt !== void 0 ? versionData.autoDecrypt : true,
|
|
9836
|
+
asyncEvents: versionData.asyncEvents !== void 0 ? versionData.asyncEvents : true,
|
|
9181
9837
|
hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : versionData.hooks || {},
|
|
9182
9838
|
versioningEnabled: this.versioningEnabled,
|
|
9183
9839
|
map: versionData.map,
|
|
@@ -9418,6 +10074,7 @@ class Database extends EventEmitter {
|
|
|
9418
10074
|
allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
|
|
9419
10075
|
autoDecrypt: resource.config.autoDecrypt,
|
|
9420
10076
|
cache: resource.config.cache,
|
|
10077
|
+
asyncEvents: resource.config.asyncEvents,
|
|
9421
10078
|
hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
|
|
9422
10079
|
idSize: resource.idSize,
|
|
9423
10080
|
idGenerator: resource.idGeneratorType,
|
|
@@ -9747,6 +10404,23 @@ class Database extends EventEmitter {
|
|
|
9747
10404
|
existingHash
|
|
9748
10405
|
};
|
|
9749
10406
|
}
|
|
10407
|
+
/**
|
|
10408
|
+
* Create or update a resource in the database
|
|
10409
|
+
* @param {Object} config - Resource configuration
|
|
10410
|
+
* @param {string} config.name - Resource name
|
|
10411
|
+
* @param {Object} config.attributes - Resource attributes schema
|
|
10412
|
+
* @param {string} [config.behavior='user-managed'] - Resource behavior strategy
|
|
10413
|
+
* @param {Object} [config.hooks] - Resource hooks
|
|
10414
|
+
* @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
|
|
10415
|
+
* @param {boolean} [config.timestamps=false] - Enable automatic timestamps
|
|
10416
|
+
* @param {Object} [config.partitions={}] - Partition definitions
|
|
10417
|
+
* @param {boolean} [config.paranoid=true] - Security flag for dangerous operations
|
|
10418
|
+
* @param {boolean} [config.cache=false] - Enable caching
|
|
10419
|
+
* @param {boolean} [config.autoDecrypt=true] - Auto-decrypt secret fields
|
|
10420
|
+
* @param {Function|number} [config.idGenerator] - Custom ID generator or size
|
|
10421
|
+
* @param {number} [config.idSize=22] - Size for auto-generated IDs
|
|
10422
|
+
* @returns {Promise<Resource>} The created or updated resource
|
|
10423
|
+
*/
|
|
9750
10424
|
async createResource({ name, attributes, behavior = "user-managed", hooks, ...config }) {
|
|
9751
10425
|
if (this.resources[name]) {
|
|
9752
10426
|
const existingResource = this.resources[name];
|
|
@@ -9802,6 +10476,7 @@ class Database extends EventEmitter {
|
|
|
9802
10476
|
map: config.map,
|
|
9803
10477
|
idGenerator: config.idGenerator,
|
|
9804
10478
|
idSize: config.idSize,
|
|
10479
|
+
asyncEvents: config.asyncEvents,
|
|
9805
10480
|
events: config.events || {}
|
|
9806
10481
|
});
|
|
9807
10482
|
resource.database = this;
|