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/PLUGINS.md +453 -102
- package/README.md +31 -2
- package/dist/s3db.cjs.js +1099 -507
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1099 -507
- package/dist/s3db.es.js.map +1 -1
- package/package.json +5 -5
- package/src/concerns/async-event-emitter.js +46 -0
- package/src/database.class.js +23 -0
- package/src/plugins/backup/base-backup-driver.class.js +119 -0
- package/src/plugins/backup/filesystem-backup-driver.class.js +254 -0
- package/src/plugins/backup/index.js +85 -0
- package/src/plugins/backup/multi-backup-driver.class.js +304 -0
- package/src/plugins/backup/s3-backup-driver.class.js +313 -0
- package/src/plugins/backup.plugin.js +375 -729
- package/src/plugins/backup.plugin.js.backup +1026 -0
- package/src/plugins/scheduler.plugin.js +0 -1
- package/src/resource.class.js +9 -6
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
|
-
|
|
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
|
-
|
|
1908
|
+
onRestoreStart: options.onRestoreStart || null,
|
|
1909
|
+
onRestoreComplete: options.onRestoreComplete || null,
|
|
1910
|
+
onRestoreError: options.onRestoreError || null
|
|
1122
1911
|
};
|
|
1123
|
-
this.
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
1142
|
-
this.
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1173
|
-
byType: { fields: { type: "string" } },
|
|
1174
|
-
byDate: { fields: { createdAt: "string|maxlength:10" } }
|
|
1175
|
-
}
|
|
1982
|
+
timestamps: true
|
|
1176
1983
|
}));
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
*
|
|
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 =
|
|
1191
|
-
|
|
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
|
-
|
|
1998
|
+
this.activeBackups.add(backupId);
|
|
1197
1999
|
if (this.config.onBackupStart) {
|
|
1198
|
-
await this._executeHook(this.config.onBackupStart, type, { backupId
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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);
|
|
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");
|
|
1230
2011
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
2018
|
+
finalPath = exportedFiles[0];
|
|
2019
|
+
const [statOk, , stats] = await tryFn(() => stat(finalPath));
|
|
2020
|
+
totalSize = statOk ? stats.size : 0;
|
|
1239
2021
|
}
|
|
1240
|
-
const
|
|
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.
|
|
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(
|
|
2031
|
+
await this._updateBackupMetadata(backupId, {
|
|
1246
2032
|
status: "completed",
|
|
1247
2033
|
size: totalSize,
|
|
1248
2034
|
checksum,
|
|
1249
|
-
|
|
2035
|
+
driverInfo: uploadResult,
|
|
1250
2036
|
duration
|
|
1251
2037
|
});
|
|
1252
2038
|
if (this.config.onBackupComplete) {
|
|
1253
|
-
const stats = { backupId, type, size: totalSize, duration,
|
|
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
|
-
|
|
2047
|
+
driverInfo: uploadResult
|
|
1262
2048
|
});
|
|
1263
2049
|
await this._cleanupOldBackups();
|
|
1264
2050
|
return {
|
|
@@ -1267,7 +2053,7 @@ class BackupPlugin extends Plugin {
|
|
|
1267
2053
|
size: totalSize,
|
|
1268
2054
|
duration,
|
|
1269
2055
|
checksum,
|
|
1270
|
-
|
|
2056
|
+
driverInfo: uploadResult
|
|
1271
2057
|
};
|
|
1272
2058
|
} finally {
|
|
1273
2059
|
await this._cleanupTempFiles(tempBackupDir);
|
|
@@ -1276,23 +2062,30 @@ class BackupPlugin extends Plugin {
|
|
|
1276
2062
|
if (this.config.onBackupError) {
|
|
1277
2063
|
await this._executeHook(this.config.onBackupError, type, { backupId, error });
|
|
1278
2064
|
}
|
|
2065
|
+
await this._updateBackupMetadata(backupId, {
|
|
2066
|
+
status: "failed",
|
|
2067
|
+
error: error.message,
|
|
2068
|
+
duration: Date.now() - startTime
|
|
2069
|
+
});
|
|
1279
2070
|
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
2071
|
throw error;
|
|
1284
2072
|
} finally {
|
|
1285
2073
|
this.activeBackups.delete(backupId);
|
|
1286
2074
|
}
|
|
1287
2075
|
}
|
|
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}`;
|
|
2080
|
+
}
|
|
1288
2081
|
async _createBackupMetadata(backupId, type) {
|
|
1289
|
-
const now =
|
|
2082
|
+
const now = /* @__PURE__ */ new Date();
|
|
1290
2083
|
const metadata = {
|
|
1291
2084
|
id: backupId,
|
|
1292
2085
|
type,
|
|
1293
2086
|
timestamp: Date.now(),
|
|
1294
2087
|
resources: [],
|
|
1295
|
-
|
|
2088
|
+
driverInfo: {},
|
|
1296
2089
|
size: 0,
|
|
1297
2090
|
status: "in_progress",
|
|
1298
2091
|
compressed: this.config.compression !== "none",
|
|
@@ -1300,9 +2093,11 @@ class BackupPlugin extends Plugin {
|
|
|
1300
2093
|
checksum: null,
|
|
1301
2094
|
error: null,
|
|
1302
2095
|
duration: 0,
|
|
1303
|
-
createdAt: now.slice(0, 10)
|
|
2096
|
+
createdAt: now.toISOString().slice(0, 10)
|
|
1304
2097
|
};
|
|
1305
|
-
await
|
|
2098
|
+
const [ok] = await tryFn(
|
|
2099
|
+
() => this.database.resource(this.config.backupMetadataResource).insert(metadata)
|
|
2100
|
+
);
|
|
1306
2101
|
return metadata;
|
|
1307
2102
|
}
|
|
1308
2103
|
async _updateBackupMetadata(backupId, updates) {
|
|
@@ -1310,460 +2105,194 @@ class BackupPlugin extends Plugin {
|
|
|
1310
2105
|
() => this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
|
|
1311
2106
|
);
|
|
1312
2107
|
}
|
|
1313
|
-
async
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
-
};
|
|
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);
|
|
1362
2112
|
}
|
|
1363
|
-
|
|
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
|
-
})
|
|
2113
|
+
const filteredResources = resourcesToBackup.filter(
|
|
2114
|
+
(name) => !this.config.exclude.includes(name)
|
|
1372
2115
|
);
|
|
1373
|
-
return
|
|
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
|
+
};
|
|
1374
2124
|
}
|
|
1375
|
-
async
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
const
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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)`);
|
|
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;
|
|
1412
2132
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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;
|
|
1428
|
-
}
|
|
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
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
_calculateManifestChecksum(manifest) {
|
|
1439
|
-
const hash = crypto.createHash("sha256");
|
|
1440
|
-
hash.update(JSON.stringify(manifest));
|
|
1441
|
-
return hash.digest("hex");
|
|
1442
|
-
}
|
|
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);
|
|
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
|
+
});
|
|
1451
2140
|
} else {
|
|
1452
|
-
|
|
1453
|
-
const output = createWriteStream(destPath);
|
|
1454
|
-
await pipeline(input, output);
|
|
2141
|
+
records = await resource.list();
|
|
1455
2142
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
totalSize += stats.size;
|
|
2143
|
+
const exportData = {
|
|
2144
|
+
resourceName,
|
|
2145
|
+
definition: resource.config,
|
|
2146
|
+
records,
|
|
2147
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2148
|
+
type
|
|
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}'`);
|
|
1468
2154
|
}
|
|
1469
2155
|
}
|
|
1470
|
-
return
|
|
2156
|
+
return exportedFiles;
|
|
1471
2157
|
}
|
|
1472
|
-
async
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
} 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);
|
|
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;
|
|
1486
2168
|
}
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
|
|
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()
|
|
1528
|
-
};
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
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)
|
|
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
|
|
1598
|
-
|
|
1599
|
-
|
|
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(
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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,
|
|
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
|
-
|
|
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(() => {
|
|
6283
|
+
for (const listener of listeners) {
|
|
6284
|
+
try {
|
|
6285
|
+
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
|
|
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,8 @@ ${errorDetails}`,
|
|
|
6897
7468
|
idGenerator: customIdGenerator,
|
|
6898
7469
|
idSize = 22,
|
|
6899
7470
|
versioningEnabled = false,
|
|
6900
|
-
events = {}
|
|
7471
|
+
events = {},
|
|
7472
|
+
asyncEvents = true
|
|
6901
7473
|
} = config;
|
|
6902
7474
|
this.name = name;
|
|
6903
7475
|
this.client = client;
|
|
@@ -6907,6 +7479,7 @@ ${errorDetails}`,
|
|
|
6907
7479
|
this.parallelism = parallelism;
|
|
6908
7480
|
this.passphrase = passphrase ?? "secret";
|
|
6909
7481
|
this.versioningEnabled = versioningEnabled;
|
|
7482
|
+
this.setAsyncMode(asyncEvents);
|
|
6910
7483
|
this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
|
|
6911
7484
|
if (typeof customIdGenerator === "number" && customIdGenerator > 0) {
|
|
6912
7485
|
this.idSize = customIdGenerator;
|
|
@@ -6923,7 +7496,8 @@ ${errorDetails}`,
|
|
|
6923
7496
|
timestamps,
|
|
6924
7497
|
partitions,
|
|
6925
7498
|
autoDecrypt,
|
|
6926
|
-
allNestedObjectsOptional
|
|
7499
|
+
allNestedObjectsOptional,
|
|
7500
|
+
asyncEvents
|
|
6927
7501
|
};
|
|
6928
7502
|
this.hooks = {
|
|
6929
7503
|
beforeInsert: [],
|
|
@@ -8814,9 +9388,6 @@ ${errorDetails}`,
|
|
|
8814
9388
|
}
|
|
8815
9389
|
return filtered;
|
|
8816
9390
|
}
|
|
8817
|
-
emit(event, ...args) {
|
|
8818
|
-
return super.emit(event, ...args);
|
|
8819
|
-
}
|
|
8820
9391
|
async replace(id, attributes) {
|
|
8821
9392
|
await this.delete(id);
|
|
8822
9393
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -9042,7 +9613,7 @@ class Database extends EventEmitter {
|
|
|
9042
9613
|
this.id = idGenerator(7);
|
|
9043
9614
|
this.version = "1";
|
|
9044
9615
|
this.s3dbVersion = (() => {
|
|
9045
|
-
const [ok, err, version] = tryFn(() => true ? "9.2.
|
|
9616
|
+
const [ok, err, version] = tryFn(() => true ? "9.2.1" : "latest");
|
|
9046
9617
|
return ok ? version : "latest";
|
|
9047
9618
|
})();
|
|
9048
9619
|
this.resources = {};
|
|
@@ -9084,6 +9655,7 @@ class Database extends EventEmitter {
|
|
|
9084
9655
|
parallelism: this.parallelism,
|
|
9085
9656
|
connectionString
|
|
9086
9657
|
});
|
|
9658
|
+
this.connectionString = connectionString;
|
|
9087
9659
|
this.bucket = this.client.bucket;
|
|
9088
9660
|
this.keyPrefix = this.client.keyPrefix;
|
|
9089
9661
|
if (!this._exitListenerRegistered) {
|
|
@@ -9174,6 +9746,7 @@ class Database extends EventEmitter {
|
|
|
9174
9746
|
paranoid: versionData.paranoid !== void 0 ? versionData.paranoid : true,
|
|
9175
9747
|
allNestedObjectsOptional: versionData.allNestedObjectsOptional !== void 0 ? versionData.allNestedObjectsOptional : true,
|
|
9176
9748
|
autoDecrypt: versionData.autoDecrypt !== void 0 ? versionData.autoDecrypt : true,
|
|
9749
|
+
asyncEvents: versionData.asyncEvents !== void 0 ? versionData.asyncEvents : true,
|
|
9177
9750
|
hooks: this.persistHooks ? this._deserializeHooks(versionData.hooks || {}) : versionData.hooks || {},
|
|
9178
9751
|
versioningEnabled: this.versioningEnabled,
|
|
9179
9752
|
map: versionData.map,
|
|
@@ -9414,6 +9987,7 @@ class Database extends EventEmitter {
|
|
|
9414
9987
|
allNestedObjectsOptional: resource.config.allNestedObjectsOptional,
|
|
9415
9988
|
autoDecrypt: resource.config.autoDecrypt,
|
|
9416
9989
|
cache: resource.config.cache,
|
|
9990
|
+
asyncEvents: resource.config.asyncEvents,
|
|
9417
9991
|
hooks: this.persistHooks ? this._serializeHooks(resource.config.hooks) : resource.config.hooks,
|
|
9418
9992
|
idSize: resource.idSize,
|
|
9419
9993
|
idGenerator: resource.idGeneratorType,
|
|
@@ -9743,6 +10317,23 @@ class Database extends EventEmitter {
|
|
|
9743
10317
|
existingHash
|
|
9744
10318
|
};
|
|
9745
10319
|
}
|
|
10320
|
+
/**
|
|
10321
|
+
* Create or update a resource in the database
|
|
10322
|
+
* @param {Object} config - Resource configuration
|
|
10323
|
+
* @param {string} config.name - Resource name
|
|
10324
|
+
* @param {Object} config.attributes - Resource attributes schema
|
|
10325
|
+
* @param {string} [config.behavior='user-managed'] - Resource behavior strategy
|
|
10326
|
+
* @param {Object} [config.hooks] - Resource hooks
|
|
10327
|
+
* @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
|
|
10328
|
+
* @param {boolean} [config.timestamps=false] - Enable automatic timestamps
|
|
10329
|
+
* @param {Object} [config.partitions={}] - Partition definitions
|
|
10330
|
+
* @param {boolean} [config.paranoid=true] - Security flag for dangerous operations
|
|
10331
|
+
* @param {boolean} [config.cache=false] - Enable caching
|
|
10332
|
+
* @param {boolean} [config.autoDecrypt=true] - Auto-decrypt secret fields
|
|
10333
|
+
* @param {Function|number} [config.idGenerator] - Custom ID generator or size
|
|
10334
|
+
* @param {number} [config.idSize=22] - Size for auto-generated IDs
|
|
10335
|
+
* @returns {Promise<Resource>} The created or updated resource
|
|
10336
|
+
*/
|
|
9746
10337
|
async createResource({ name, attributes, behavior = "user-managed", hooks, ...config }) {
|
|
9747
10338
|
if (this.resources[name]) {
|
|
9748
10339
|
const existingResource = this.resources[name];
|
|
@@ -9798,6 +10389,7 @@ class Database extends EventEmitter {
|
|
|
9798
10389
|
map: config.map,
|
|
9799
10390
|
idGenerator: config.idGenerator,
|
|
9800
10391
|
idSize: config.idSize,
|
|
10392
|
+
asyncEvents: config.asyncEvents,
|
|
9801
10393
|
events: config.events || {}
|
|
9802
10394
|
});
|
|
9803
10395
|
resource.database = this;
|