s3db.js 9.2.0 → 9.2.1

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/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
- destinations: options.destinations || [],
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
- ...options
1912
+ onRestoreStart: options.onRestoreStart || null,
1913
+ onRestoreComplete: options.onRestoreComplete || null,
1914
+ onRestoreError: options.onRestoreError || null
1126
1915
  };
1127
- this.database = null;
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
- _validateConfiguration() {
1133
- if (this.config.destinations.length === 0) {
1134
- throw new Error("BackupPlugin: At least one destination must be configured");
1135
- }
1136
- for (const dest of this.config.destinations) {
1137
- if (!dest.type) {
1138
- throw new Error("BackupPlugin: Each destination must have a type");
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 setup(database) {
1146
- this.database = database;
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
- await this._ensureTempDirectory();
1149
- if (Object.keys(this.config.schedule).length > 0) {
1150
- await this._setupScheduledBackups();
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
- destinations: this.config.destinations.length,
1154
- scheduled: Object.keys(this.config.schedule)
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
- destinations: "json|required",
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
- partitions: {
1177
- byType: { fields: { type: "string" } },
1178
- byDate: { fields: { createdAt: "string|maxlength:10" } }
1179
- }
1986
+ timestamps: true
1180
1987
  }));
1181
- }
1182
- async _ensureTempDirectory() {
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
- * Perform a backup
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 = `backup_${type}_${Date.now()}`;
1195
- if (this.activeBackups.has(backupId)) {
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
- const startTime = Date.now();
2002
+ this.activeBackups.add(backupId);
1201
2003
  if (this.config.onBackupStart) {
1202
- await this._executeHook(this.config.onBackupStart, type, { backupId, ...options });
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
- for (const resourceName of resources) {
1213
- const resourceData = await this._backupResource(resourceName, type);
1214
- const filePath = path.join(tempBackupDir, `${resourceName}.json`);
1215
- await promises.writeFile(filePath, JSON.stringify(resourceData, null, 2));
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);
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");
1234
2015
  }
1235
- if (this.config.encryption) {
1236
- finalPath = await this._encryptBackup(finalPath, backupId);
1237
- }
1238
- let checksum = null;
1239
- if (this.config.compression !== "none" || this.config.encryption) {
1240
- checksum = await this._calculateChecksum(finalPath);
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
- checksum = this._calculateManifestChecksum(manifest);
2022
+ finalPath = exportedFiles[0];
2023
+ const [statOk, , stats] = await tryFn(() => promises.stat(finalPath));
2024
+ totalSize = statOk ? stats.size : 0;
1243
2025
  }
1244
- const uploadResults = await this._uploadToDestinations(finalPath, backupId, manifest);
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._verifyBackup(backupId, checksum);
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(metadata.id, {
2035
+ await this._updateBackupMetadata(backupId, {
1250
2036
  status: "completed",
1251
2037
  size: totalSize,
1252
2038
  checksum,
1253
- destinations: uploadResults,
2039
+ driverInfo: uploadResult,
1254
2040
  duration
1255
2041
  });
1256
2042
  if (this.config.onBackupComplete) {
1257
- const stats = { backupId, type, size: totalSize, duration, destinations: uploadResults.length };
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
- destinations: uploadResults.length
2051
+ driverInfo: uploadResult
1266
2052
  });
1267
2053
  await this._cleanupOldBackups();
1268
2054
  return {
@@ -1271,7 +2057,7 @@ class BackupPlugin extends Plugin {
1271
2057
  size: totalSize,
1272
2058
  duration,
1273
2059
  checksum,
1274
- destinations: uploadResults
2060
+ driverInfo: uploadResult
1275
2061
  };
1276
2062
  } finally {
1277
2063
  await this._cleanupTempFiles(tempBackupDir);
@@ -1280,23 +2066,30 @@ class BackupPlugin extends Plugin {
1280
2066
  if (this.config.onBackupError) {
1281
2067
  await this._executeHook(this.config.onBackupError, type, { backupId, error });
1282
2068
  }
2069
+ await this._updateBackupMetadata(backupId, {
2070
+ status: "failed",
2071
+ error: error.message,
2072
+ duration: Date.now() - startTime
2073
+ });
1283
2074
  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
2075
  throw error;
1288
2076
  } finally {
1289
2077
  this.activeBackups.delete(backupId);
1290
2078
  }
1291
2079
  }
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}`;
2084
+ }
1292
2085
  async _createBackupMetadata(backupId, type) {
1293
- const now = (/* @__PURE__ */ new Date()).toISOString();
2086
+ const now = /* @__PURE__ */ new Date();
1294
2087
  const metadata = {
1295
2088
  id: backupId,
1296
2089
  type,
1297
2090
  timestamp: Date.now(),
1298
2091
  resources: [],
1299
- destinations: [],
2092
+ driverInfo: {},
1300
2093
  size: 0,
1301
2094
  status: "in_progress",
1302
2095
  compressed: this.config.compression !== "none",
@@ -1304,9 +2097,11 @@ class BackupPlugin extends Plugin {
1304
2097
  checksum: null,
1305
2098
  error: null,
1306
2099
  duration: 0,
1307
- createdAt: now.slice(0, 10)
2100
+ createdAt: now.toISOString().slice(0, 10)
1308
2101
  };
1309
- await this.database.resource(this.config.backupMetadataResource).insert(metadata);
2102
+ const [ok] = await tryFn(
2103
+ () => this.database.resource(this.config.backupMetadataResource).insert(metadata)
2104
+ );
1310
2105
  return metadata;
1311
2106
  }
1312
2107
  async _updateBackupMetadata(backupId, updates) {
@@ -1314,460 +2109,194 @@ class BackupPlugin extends Plugin {
1314
2109
  () => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
1315
2110
  );
1316
2111
  }
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
- };
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);
1366
2116
  }
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
- })
2117
+ const filteredResources = resourcesToBackup.filter(
2118
+ (name) => !this.config.exclude.includes(name)
1376
2119
  );
1377
- return ok && backups.length > 0 ? backups[0] : null;
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
+ };
1378
2128
  }
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)`);
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;
1416
2136
  }
1417
- return compressedPath;
1418
- } catch (error) {
1419
- throw new Error(`Failed to compress backup: ${error.message}`);
1420
- }
1421
- }
1422
- async _encryptBackup(filePath, backupId) {
1423
- if (!this.config.encryption) return filePath;
1424
- const encryptedPath = `${filePath}.enc`;
1425
- const { algorithm, key } = this.config.encryption;
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;
1432
- }
1433
- async _calculateChecksum(filePath) {
1434
- const hash = crypto.createHash("sha256");
1435
- const input = fs.createReadStream(filePath);
1436
- return new Promise((resolve, reject) => {
1437
- input.on("data", (data) => hash.update(data));
1438
- input.on("end", () => resolve(hash.digest("hex")));
1439
- input.on("error", reject);
1440
- });
1441
- }
1442
- _calculateManifestChecksum(manifest) {
1443
- const hash = crypto.createHash("sha256");
1444
- hash.update(JSON.stringify(manifest));
1445
- return hash.digest("hex");
1446
- }
1447
- async _copyDirectory(src, dest) {
1448
- await promises.mkdir(dest, { recursive: true });
1449
- const entries = await promises.readdir(src, { withFileTypes: true });
1450
- for (const entry of entries) {
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);
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
+ });
1455
2144
  } else {
1456
- const input = fs.createReadStream(srcPath);
1457
- const output = fs.createWriteStream(destPath);
1458
- await promises$1.pipeline(input, output);
2145
+ records = await resource.list();
1459
2146
  }
1460
- }
1461
- }
1462
- async _getDirectorySize(dirPath) {
1463
- let totalSize = 0;
1464
- const entries = await promises.readdir(dirPath, { withFileTypes: true });
1465
- for (const entry of entries) {
1466
- const entryPath = path.join(dirPath, entry.name);
1467
- if (entry.isDirectory()) {
1468
- totalSize += await this._getDirectorySize(entryPath);
1469
- } else {
1470
- const stats = await promises.stat(entryPath);
1471
- totalSize += stats.size;
2147
+ const exportData = {
2148
+ resourceName,
2149
+ definition: resource.config,
2150
+ records,
2151
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2152
+ type
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}'`);
1472
2158
  }
1473
2159
  }
1474
- return totalSize;
2160
+ return exportedFiles;
1475
2161
  }
1476
- async _uploadToDestinations(filePath, backupId, manifest) {
1477
- const results = [];
1478
- let hasSuccess = false;
1479
- for (const destination of this.config.destinations) {
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;
1486
- } else {
1487
- results.push({ ...destination, status: "failed", error: err.message });
1488
- if (this.config.verbose) {
1489
- console.warn(`[BackupPlugin] Upload to ${destination.type} failed:`, err.message);
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;
1490
2172
  }
1491
- }
1492
- }
1493
- if (!hasSuccess) {
1494
- const errors = results.map((r) => r.error).join("; ");
1495
- throw new Error(`All backup destinations failed: ${errors}`);
1496
- }
1497
- return results;
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()
1532
- };
1533
- }
1534
- }
1535
- async _uploadToS3(filePath, backupId, destination) {
1536
- const key = destination.path.replace("{date}", (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)).replace("{backupId}", backupId) + path.basename(filePath);
1537
- await new Promise((resolve) => setTimeout(resolve, 1e3));
1538
- return {
1539
- bucket: destination.bucket,
1540
- key,
1541
- uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1542
- };
1543
- }
1544
- async _verifyBackup(backupId, expectedChecksum) {
1545
- if (this.config.verbose) {
1546
- console.log(`[BackupPlugin] Verifying backup ${backupId} with checksum ${expectedChecksum}`);
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)
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 _deleteFromDestination(backup, destination) {
1602
- if (this.config.verbose) {
1603
- console.log(`[BackupPlugin] Deleting backup ${backup.id} from ${destination.type}`);
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(async () => {
1608
- const files = await this._getDirectoryFiles(tempDir);
1609
- for (const file of files) {
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
- const files = await promises.readdir(tempDir);
1683
- const compressedFile = files.find((f) => f.endsWith(".tar.gz"));
1684
- if (!compressedFile) {
1685
- throw new Error("No compressed backup file found");
1686
- }
1687
- const compressedPath = path.join(tempDir, compressedFile);
1688
- const compressedData = await promises.readFile(compressedPath, "utf8");
1689
- const backupId = path.basename(compressedFile, ".tar.gz");
1690
- const backup = await this._getBackupMetadata(backupId);
1691
- const compressionType = backup?.compression || "gzip";
1692
- let decompressed;
1693
- if (compressionType === "none") {
1694
- decompressed = compressedData;
1695
- } else {
1696
- const compressedBuffer = Buffer.from(compressedData, "base64");
1697
- switch (compressionType) {
1698
- case "gzip":
1699
- decompressed = zlib.gunzipSync(compressedBuffer).toString("utf8");
1700
- break;
1701
- case "brotli":
1702
- decompressed = zlib.brotliDecompressSync(compressedBuffer).toString("utf8");
1703
- break;
1704
- case "deflate":
1705
- decompressed = zlib.inflateSync(compressedBuffer).toString("utf8");
1706
- break;
1707
- default:
1708
- throw new Error(`Unsupported compression type: ${compressionType}`);
1709
- }
1710
- }
1711
- const backupData = JSON.parse(decompressed);
1712
- for (const [filename, content] of Object.entries(backupData)) {
1713
- const filePath = path.join(tempDir, filename);
1714
- await promises.writeFile(filePath, content, "utf8");
1715
- }
1716
- await promises.unlink(compressedPath);
1717
- if (this.config.verbose) {
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
- throw new Error(`Failed to decompress backup: ${error.message}`);
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
- const { type = null, status = null, limit = 50 } = options;
1742
- const [ok, err, allBackups] = await tryFn(
1743
- () => this.database.resource(this.config.backupMetadataResource).list({
1744
- orderBy: { timestamp: "desc" },
1745
- limit: limit * 2
1746
- // Get more to filter client-side
1747
- })
1748
- );
1749
- if (!ok) return [];
1750
- let filteredBackups = allBackups;
1751
- if (type) {
1752
- filteredBackups = filteredBackups.filter((backup) => backup.type === type);
1753
- }
1754
- if (status) {
1755
- filteredBackups = filteredBackups.filter((backup) => backup.status === status);
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, err, backup] = await tryFn(
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
- console.log(`[BackupPlugin] Started with ${this.config.destinations.length} destinations`);
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(() => {
6287
+ for (const listener of listeners) {
6288
+ try {
6289
+ 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 EventEmitter {
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,8 @@ ${errorDetails}`,
6901
7472
  idGenerator: customIdGenerator,
6902
7473
  idSize = 22,
6903
7474
  versioningEnabled = false,
6904
- events = {}
7475
+ events = {},
7476
+ asyncEvents = true
6905
7477
  } = config;
6906
7478
  this.name = name;
6907
7479
  this.client = client;
@@ -6911,6 +7483,7 @@ ${errorDetails}`,
6911
7483
  this.parallelism = parallelism;
6912
7484
  this.passphrase = passphrase ?? "secret";
6913
7485
  this.versioningEnabled = versioningEnabled;
7486
+ this.setAsyncMode(asyncEvents);
6914
7487
  this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
6915
7488
  if (typeof customIdGenerator === "number" && customIdGenerator > 0) {
6916
7489
  this.idSize = customIdGenerator;
@@ -6927,7 +7500,8 @@ ${errorDetails}`,
6927
7500
  timestamps,
6928
7501
  partitions,
6929
7502
  autoDecrypt,
6930
- allNestedObjectsOptional
7503
+ allNestedObjectsOptional,
7504
+ asyncEvents
6931
7505
  };
6932
7506
  this.hooks = {
6933
7507
  beforeInsert: [],
@@ -8818,9 +9392,6 @@ ${errorDetails}`,
8818
9392
  }
8819
9393
  return filtered;
8820
9394
  }
8821
- emit(event, ...args) {
8822
- return super.emit(event, ...args);
8823
- }
8824
9395
  async replace(id, attributes) {
8825
9396
  await this.delete(id);
8826
9397
  await new Promise((r) => setTimeout(r, 100));
@@ -9046,7 +9617,7 @@ class Database extends EventEmitter {
9046
9617
  this.id = idGenerator(7);
9047
9618
  this.version = "1";
9048
9619
  this.s3dbVersion = (() => {
9049
- const [ok, err, version] = tryFn(() => true ? "9.2.0" : "latest");
9620
+ const [ok, err, version] = tryFn(() => true ? "9.2.1" : "latest");
9050
9621
  return ok ? version : "latest";
9051
9622
  })();
9052
9623
  this.resources = {};
@@ -9088,6 +9659,7 @@ class Database extends EventEmitter {
9088
9659
  parallelism: this.parallelism,
9089
9660
  connectionString
9090
9661
  });
9662
+ this.connectionString = connectionString;
9091
9663
  this.bucket = this.client.bucket;
9092
9664
  this.keyPrefix = this.client.keyPrefix;
9093
9665
  if (!this._exitListenerRegistered) {
@@ -9178,6 +9750,7 @@ class Database extends EventEmitter {
9178
9750
  paranoid: versionData.paranoid !== void 0 ? versionData.paranoid : true,
9179
9751
  allNestedObjectsOptional: versionData.allNestedObjectsOptional !== void 0 ? versionData.allNestedObjectsOptional : true,
9180
9752
  autoDecrypt: versionData.autoDecrypt !== void 0 ? versionData.autoDecrypt : true,
9753
+ asyncEvents: versionData.asyncEvents !== void 0 ? versionData.asyncEvents : true,
9181
9754
  hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : versionData.hooks || {},
9182
9755
  versioningEnabled: this.versioningEnabled,
9183
9756
  map: versionData.map,
@@ -9418,6 +9991,7 @@ class Database extends EventEmitter {
9418
9991
  allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
9419
9992
  autoDecrypt: resource.config.autoDecrypt,
9420
9993
  cache: resource.config.cache,
9994
+ asyncEvents: resource.config.asyncEvents,
9421
9995
  hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
9422
9996
  idSize: resource.idSize,
9423
9997
  idGenerator: resource.idGeneratorType,
@@ -9747,6 +10321,23 @@ class Database extends EventEmitter {
9747
10321
  existingHash
9748
10322
  };
9749
10323
  }
10324
+ /**
10325
+ * Create or update a resource in the database
10326
+ * @param {Object} config - Resource configuration
10327
+ * @param {string} config.name - Resource name
10328
+ * @param {Object} config.attributes - Resource attributes schema
10329
+ * @param {string} [config.behavior='user-managed'] - Resource behavior strategy
10330
+ * @param {Object} [config.hooks] - Resource hooks
10331
+ * @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
10332
+ * @param {boolean} [config.timestamps=false] - Enable automatic timestamps
10333
+ * @param {Object} [config.partitions={}] - Partition definitions
10334
+ * @param {boolean} [config.paranoid=true] - Security flag for dangerous operations
10335
+ * @param {boolean} [config.cache=false] - Enable caching
10336
+ * @param {boolean} [config.autoDecrypt=true] - Auto-decrypt secret fields
10337
+ * @param {Function|number} [config.idGenerator] - Custom ID generator or size
10338
+ * @param {number} [config.idSize=22] - Size for auto-generated IDs
10339
+ * @returns {Promise<Resource>} The created or updated resource
10340
+ */
9750
10341
  async createResource({ name, attributes, behavior = "user-managed", hooks, ...config }) {
9751
10342
  if (this.resources[name]) {
9752
10343
  const existingResource = this.resources[name];
@@ -9802,6 +10393,7 @@ class Database extends EventEmitter {
9802
10393
  map: config.map,
9803
10394
  idGenerator: config.idGenerator,
9804
10395
  idSize: config.idSize,
10396
+ asyncEvents: config.asyncEvents,
9805
10397
  events: config.events || {}
9806
10398
  });
9807
10399
  resource.database = this;