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