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