mbkbucket 4.0.1 → 4.2.0

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.
Files changed (35) hide show
  1. package/.env.example +3 -2
  2. package/README.md +96 -44
  3. package/index.js +4 -1
  4. package/lib/cli.js +1 -1
  5. package/lib/public/bucketadmin.js +255 -33
  6. package/lib/public/mbkbucket-helper-demo.html +0 -58
  7. package/lib/public/mbkbucket-helper.js +1 -9
  8. package/lib/src/app.js +10 -24
  9. package/lib/src/config/validation.js +6 -1
  10. package/lib/src/controllers/api.controller.js +34 -6
  11. package/lib/src/controllers/dashboard.controller.js +2 -2
  12. package/lib/src/controllers/info.controller.js +15 -14
  13. package/lib/src/controllers/view.controller.js +24 -22
  14. package/lib/src/core/metrics.js +134 -0
  15. package/lib/src/core/storage/capabilities.js +58 -0
  16. package/lib/src/core/storage/errors.js +0 -5
  17. package/lib/src/core/storage/gdrive-auth.js +0 -36
  18. package/lib/src/core/storage/gdrive-provider.js +103 -44
  19. package/lib/src/core/storage/index.js +4 -1
  20. package/lib/src/core/storage/local-provider.js +508 -0
  21. package/lib/src/core/storage/models.js +91 -50
  22. package/lib/src/core/storage/path-strategy.js +22 -2
  23. package/lib/src/core/storage/s3-provider.js +19 -38
  24. package/lib/src/core/storage/storage-manager.js +76 -53
  25. package/lib/src/core/storage/storage-provider.js +49 -120
  26. package/lib/src/middleware/error-handler.js +15 -52
  27. package/lib/src/middleware/rate-limiter.js +1 -1
  28. package/lib/src/routes/api.routes.js +4 -1
  29. package/lib/src/services/s3.service.js +2 -2
  30. package/lib/src/services/storage.service.js +118 -20
  31. package/lib/src/utils/cleanup-scheduler.js +91 -0
  32. package/lib/src/utils/helpers.js +8 -51
  33. package/package.json +4 -4
  34. package/views/bucket.hbs +0 -5
  35. package/views/bucketadmin_preview_modal.hbs +3 -3
@@ -6,13 +6,33 @@
6
6
 
7
7
  import { mbkautheVar } from "mbkauthe";
8
8
  import { StorageValidationError } from "./errors.js";
9
- import { trimLeadingSlashes, trimSlashes } from "#helpers";
9
+ import { trimLeadingSlashes, trimSlashes } from "mbkutils";
10
10
 
11
11
  const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
12
 
13
+ let _configuredAppName = null;
14
+
13
15
  export class PathStrategy {
16
+ static setAppName(name) {
17
+ _configuredAppName = name ? String(name).trim() : null;
18
+ }
19
+
20
+ static resetAppName() {
21
+ _configuredAppName = null;
22
+ }
23
+
14
24
  static getAppName() {
15
- const app = mbkautheVar?.APP_NAME;
25
+ if (_configuredAppName !== null) {
26
+ return _configuredAppName;
27
+ }
28
+ let envAppName = null;
29
+ if (process.env.mbkautheVar) {
30
+ try {
31
+ const parsed = typeof process.env.mbkautheVar === 'string' ? JSON.parse(process.env.mbkautheVar) : process.env.mbkautheVar;
32
+ if (parsed?.APP_NAME) envAppName = parsed.APP_NAME;
33
+ } catch {}
34
+ }
35
+ const app = envAppName || process.env.MBKAUTHE_APP_NAME || process.env.APP_NAME || mbkautheVar?.APP_NAME;
16
36
  return typeof app === 'string' && app.trim() ? app.trim() : '';
17
37
  }
18
38
 
@@ -6,18 +6,16 @@
6
6
 
7
7
  import { S3Client, ListObjectsV2Command, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListMultipartUploadsCommand, CopyObjectCommand } from '@aws-sdk/client-s3';
8
8
  import { StorageProvider } from './storage-provider.js';
9
+ import { StorageCapabilities } from './capabilities.js';
9
10
  import { StorageFile, StorageFolder, StorageListResult } from './models.js';
10
11
  import { StorageNotFoundError, StorageConflictError, StorageAccessDeniedError, StorageValidationError, StorageError } from './errors.js';
11
12
  import { createLogger } from "#logger";
12
- import { nowIso, trimSlashes } from "#helpers";
13
+ import { nowIso } from "#helpers";
14
+ import { trimSlashes } from "mbkutils";
13
15
 
14
16
  const debugS3 = createLogger('s3-provider');
15
17
 
16
18
  export class S3StorageProvider extends StorageProvider {
17
- /**
18
- * @param {string} name - Connection name
19
- * @param {object} config - S3 configuration (BUCKET_NAME, ACCESS_KEY_ID, SECRET_ACCESS_KEY, ENDPOINT, region)
20
- */
21
19
  constructor(name, config = {}) {
22
20
  super(name, config);
23
21
  this.bucket = config.BUCKET_NAME;
@@ -49,16 +47,19 @@ export class S3StorageProvider extends StorageProvider {
49
47
  }
50
48
 
51
49
  get capabilities() {
52
- return {
50
+ return new StorageCapabilities({
53
51
  multipart: true,
54
- presignedUrls: true,
52
+ resumable: true,
55
53
  nativeFolders: false,
56
54
  ranges: true,
57
55
  copy: true,
58
56
  move: true,
59
57
  search: false,
60
- directDownloadUrl: false
61
- };
58
+ directDownloadUrl: false,
59
+ versioning: false,
60
+ publicUrls: false,
61
+ metadata: true
62
+ });
62
63
  }
63
64
 
64
65
  async checkHealth() {
@@ -279,7 +280,8 @@ export class S3StorageProvider extends StorageProvider {
279
280
  size: item.Size,
280
281
  lastModified: item.LastModified,
281
282
  etag: item.ETag,
282
- storageClass: item.StorageClass || 'STANDARD'
283
+ storageClass: item.StorageClass || 'STANDARD',
284
+ provider: 's3'
283
285
  }));
284
286
 
285
287
  const commonPrefixes = (result.CommonPrefixes || []).map(p => new StorageFolder(p.Prefix));
@@ -340,6 +342,7 @@ export class S3StorageProvider extends StorageProvider {
340
342
  }
341
343
 
342
344
  async copyFile(sourceKey, destKey, options = {}) {
345
+ this.assertCapability('copy', 'copyFile');
343
346
  try {
344
347
  const copySource = encodeURIComponent(`${this.bucket}/${sourceKey}`);
345
348
  await this.client.send(new CopyObjectCommand({
@@ -357,39 +360,12 @@ export class S3StorageProvider extends StorageProvider {
357
360
  }
358
361
  }
359
362
 
360
- async generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, _options = {}) {
361
- try {
362
- const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
363
- const command = operation === 'getObject'
364
- ? new GetObjectCommand({ Bucket: this.bucket, Key: key })
365
- : operation === 'putObject'
366
- ? new PutObjectCommand({ Bucket: this.bucket, Key: key })
367
- : null;
368
-
369
- if (!command) throw new StorageValidationError(`Unsupported operation: ${operation}`);
370
-
371
- const signedUrl = await getSignedUrl(this.client, command, { expiresIn });
372
- const now = Date.now();
373
- return {
374
- url: signedUrl,
375
- key,
376
- operation,
377
- expiresIn,
378
- expiresAt: new Date(now + expiresIn * 1000).toISOString(),
379
- generatedAt: new Date(now).toISOString()
380
- };
381
- } catch (error) {
382
- debugS3('Generate signed URL failed for key %s: %s', key, error.message);
383
- if (error instanceof StorageError) throw error;
384
- throw new StorageError(`Generate signed URL failed: ${error.message}`);
385
- }
386
- }
387
-
388
363
  // ---------------------------------------------------------------------------
389
364
  // S3 Multipart Upload Operations
390
365
  // ---------------------------------------------------------------------------
391
366
 
392
367
  async createMultipartUpload(key, contentType = 'application/octet-stream', metadata = {}) {
368
+ this.assertCapability('multipart', 'createMultipartUpload');
393
369
  const result = await this.client.send(new CreateMultipartUploadCommand({
394
370
  Bucket: this.bucket,
395
371
  Key: key,
@@ -404,6 +380,7 @@ export class S3StorageProvider extends StorageProvider {
404
380
  }
405
381
 
406
382
  async uploadPart(key, uploadId, partNumber, buffer) {
383
+ this.assertCapability('multipart', 'uploadPart');
407
384
  const result = await this.client.send(new UploadPartCommand({
408
385
  Bucket: this.bucket,
409
386
  Key: key,
@@ -416,6 +393,7 @@ export class S3StorageProvider extends StorageProvider {
416
393
  }
417
394
 
418
395
  async completeMultipartUpload(key, uploadId, parts) {
396
+ this.assertCapability('multipart', 'completeMultipartUpload');
419
397
  const sorted = [...parts].sort((a, b) => a.partNumber - b.partNumber);
420
398
  await this.client.send(new CompleteMultipartUploadCommand({
421
399
  Bucket: this.bucket,
@@ -429,6 +407,7 @@ export class S3StorageProvider extends StorageProvider {
429
407
  }
430
408
 
431
409
  async abortMultipartUpload(key, uploadId) {
410
+ this.assertCapability('multipart', 'abortMultipartUpload');
432
411
  await this.client.send(new AbortMultipartUploadCommand({
433
412
  Bucket: this.bucket,
434
413
  Key: key,
@@ -438,6 +417,7 @@ export class S3StorageProvider extends StorageProvider {
438
417
  }
439
418
 
440
419
  async listIncompleteMultipartUploads(prefix = '') {
420
+ this.assertCapability('multipart', 'listIncompleteMultipartUploads');
441
421
  try {
442
422
  const result = await this.client.send(new ListMultipartUploadsCommand({
443
423
  Bucket: this.bucket,
@@ -452,6 +432,7 @@ export class S3StorageProvider extends StorageProvider {
452
432
  }
453
433
 
454
434
  async cleanupIncompleteMultipartUploads(olderThanDays = 7, prefix = '') {
435
+ this.assertCapability('multipart', 'cleanupIncompleteMultipartUploads');
455
436
  try {
456
437
  const uploads = await this.listIncompleteMultipartUploads(prefix);
457
438
  if (!uploads.length) {
@@ -1,13 +1,15 @@
1
1
  /**
2
- * MBKBucket - Storage Manager & Provider Registry
2
+ * MBKBucket - Storage Manager & Multi-Provider Connection Registry
3
3
  * Copyright (c) 2026 Muhammad Bin Khalid, MBKTech.org and contributors
4
4
  * Licensed under the MIT License.
5
5
  */
6
6
 
7
+ import { EventEmitter } from "events";
7
8
  import dotenv from "dotenv";
8
9
  import { mbkautheVar } from "mbkauthe";
9
10
  import { S3StorageProvider } from "./s3-provider.js";
10
11
  import { GoogleDriveStorageProvider } from "./gdrive-provider.js";
12
+ import { LocalStorageProvider } from "./local-provider.js";
11
13
  import { StorageConfigError, StorageValidationError } from "./errors.js";
12
14
  import { createLogger } from "#logger";
13
15
 
@@ -24,29 +26,36 @@ function parseJsonEnv(envVar, fallback = {}) {
24
26
  }
25
27
  }
26
28
 
27
- export class StorageManager {
29
+ export class StorageManager extends EventEmitter {
28
30
  constructor() {
31
+ super();
29
32
  this._providers = new Map();
30
33
  this._factories = new Map();
31
34
  this._configs = new Map();
35
+ this._defaultConnectionName = null;
32
36
 
33
- // Register built-in provider drivers
34
37
  this.registerDriver('s3', (name, cfg) => new S3StorageProvider(name, cfg));
35
38
  this.registerDriver('r2', (name, cfg) => new S3StorageProvider(name, cfg));
36
39
  this.registerDriver('minio', (name, cfg) => new S3StorageProvider(name, cfg));
40
+ this.registerDriver('idrive', (name, cfg) => new S3StorageProvider(name, cfg));
37
41
  this.registerDriver('gdrive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
38
42
  this.registerDriver('google-drive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
39
43
  this.registerDriver('googledrive', (name, cfg) => new GoogleDriveStorageProvider(name, cfg));
44
+ this.registerDriver('local', (name, cfg) => new LocalStorageProvider(name, cfg));
45
+ this.registerDriver('fs', (name, cfg) => new LocalStorageProvider(name, cfg));
46
+ this.registerDriver('filesystem', (name, cfg) => new LocalStorageProvider(name, cfg));
40
47
 
41
48
  this.reloadFromEnv();
42
49
  }
43
50
 
44
- /**
45
- * Register a custom provider driver.
46
- * @param {string} type
47
- * @param {(name: string, config: object) => import('./storage-provider.js').StorageProvider} factory
48
- */
51
+
49
52
  registerDriver(type, factory) {
53
+ if (!type || typeof type !== 'string') {
54
+ throw new StorageValidationError("Driver type must be a non-empty string");
55
+ }
56
+ if (typeof factory !== 'function') {
57
+ throw new StorageValidationError("Driver factory must be a function");
58
+ }
50
59
  this._factories.set(String(type).toLowerCase(), factory);
51
60
  }
52
61
 
@@ -54,9 +63,14 @@ export class StorageManager {
54
63
  this.registerDriver(type, factory);
55
64
  }
56
65
 
57
- /**
58
- * Reload all connections from environment variables.
59
- */
66
+ hasDriver(type) {
67
+ return this._factories.has(String(type).toLowerCase());
68
+ }
69
+
70
+ getDriverTypes() {
71
+ return Array.from(this._factories.keys());
72
+ }
73
+
60
74
  reloadFromEnv() {
61
75
  this._providers.clear();
62
76
  this._configs.clear();
@@ -71,31 +85,39 @@ export class StorageManager {
71
85
  }
72
86
  }
73
87
 
74
- /**
75
- * Register a named storage connection.
76
- * @param {string} name
77
- * @param {object} config
78
- */
79
88
  registerConnection(name, config) {
80
- const cleanName = String(name).trim();
89
+ const cleanName = String(name || '').trim();
81
90
  if (!cleanName) throw new StorageValidationError("Connection name cannot be empty");
91
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
92
+ throw new StorageValidationError(`Connection configuration for '${cleanName}' must be an object`);
93
+ }
94
+
82
95
  this._configs.set(cleanName, Object.freeze({ ...config }));
83
96
  this._providers.delete(cleanName);
84
97
  }
85
98
 
86
- /**
87
- * Get all registered connection names.
88
- * @returns {string[]}
89
- */
99
+ unregisterConnection(name) {
100
+ const cleanName = String(name || '').trim();
101
+ this._configs.delete(cleanName);
102
+ this._providers.delete(cleanName);
103
+ }
104
+
105
+ hasConnection(name) {
106
+ return this._configs.has(String(name || '').trim());
107
+ }
108
+
90
109
  getAvailableConnectionNames() {
91
110
  return Array.from(this._configs.keys());
92
111
  }
93
112
 
94
- /**
95
- * Get the default connection name.
96
- * @returns {string|null}
97
- */
113
+ setDefaultConnectionName(name) {
114
+ this._defaultConnectionName = name ? String(name).trim() : null;
115
+ }
116
+
98
117
  getDefaultConnectionName() {
118
+ if (this._defaultConnectionName && this._configs.has(this._defaultConnectionName)) {
119
+ return this._defaultConnectionName;
120
+ }
99
121
  const configuredDefault = mbkautheVar?.bucket;
100
122
  if (configuredDefault && this._configs.has(configuredDefault)) {
101
123
  return configuredDefault;
@@ -104,57 +126,61 @@ export class StorageManager {
104
126
  return available.length > 0 ? available[0] : null;
105
127
  }
106
128
 
107
- /**
108
- * Resolve a candidate connection name or fall back to default.
109
- * @param {string} [name]
110
- * @returns {string}
111
- */
112
129
  resolveConnectionName(name) {
113
130
  const raw = typeof name === 'string' ? name.trim() : name;
114
131
  const candidate = !raw ? this.getDefaultConnectionName() : String(raw);
115
132
 
116
133
  const available = this.getAvailableConnectionNames();
117
134
  if (!candidate) {
135
+ if (available.length === 0) {
136
+ throw new StorageConfigError('BucketConnection environment variable is not set or empty. Please configure it with your storage connections.');
137
+ }
118
138
  throw new StorageValidationError('No storage connection selected. Provide ?bucket=<name> or configure a default bucket in mbkautheVar.bucket.');
119
139
  }
140
+ if (!this._configs.has(candidate)) {
141
+ throw new StorageValidationError(`Storage connection '${candidate}' not found in BucketConnection. Available connections: ${available.join(', ') || 'none'}`);
142
+ }
120
143
  if (available.length === 0) {
121
144
  throw new StorageConfigError('BucketConnection environment variable is not set or empty. Please configure it with your storage connections.');
122
145
  }
123
- if (!this._configs.has(candidate)) {
124
- throw new StorageValidationError(`Storage connection '${candidate}' not found in BucketConnection. Available connections: ${available.join(', ')}`);
125
- }
126
146
  return candidate;
127
147
  }
128
148
 
129
- /**
130
- * Get connection configuration object.
131
- * @param {string} [name]
132
- * @returns {object}
133
- */
134
149
  getConnectionConfig(name) {
135
150
  const resolved = this.resolveConnectionName(name);
136
151
  return this._configs.get(resolved);
137
152
  }
138
153
 
139
- /**
140
- * Determine the provider type from configuration.
141
- * @param {object} config
142
- * @returns {string}
143
- */
144
154
  inferProviderType(config = {}) {
145
155
  if (config?.type) return String(config.type).toLowerCase();
146
156
  if (config?.provider) return String(config.provider).toLowerCase();
157
+ if (config?.basePath || config?.rootPath || config?.directory) {
158
+ return 'local';
159
+ }
147
160
  if (config?.client_id || config?.refresh_token || config?.service_account_key || config?.folder_id || config?.service_account_email) {
148
161
  return 'gdrive';
149
162
  }
150
163
  return 's3';
151
164
  }
152
165
 
153
- /**
154
- * Get or instantiate the StorageProvider for a connection.
155
- * @param {string} [name]
156
- * @returns {import('./storage-provider.js').StorageProvider}
157
- */
166
+ clearCache() {
167
+ this._providers.clear();
168
+ }
169
+
170
+ async dispose() {
171
+ for (const [name, provider] of this._providers.entries()) {
172
+ try {
173
+ if (typeof provider.dispose === 'function') {
174
+ await provider.dispose();
175
+ }
176
+ } catch (err) {
177
+ debugStorage('Error disposing provider "%s": %s', name, err.message);
178
+ }
179
+ }
180
+ this._providers.clear();
181
+ this.removeAllListeners();
182
+ }
183
+
158
184
  getProvider(name) {
159
185
  const resolved = this.resolveConnectionName(name);
160
186
  if (this._providers.has(resolved)) {
@@ -172,14 +198,10 @@ export class StorageManager {
172
198
  debugStorage('Instantiating provider "%s" (type: %s)', resolved, providerType);
173
199
  const provider = factory(resolved, config);
174
200
  this._providers.set(resolved, provider);
201
+ this.emit('provider:instantiated', { name: resolved, type: providerType });
175
202
  return provider;
176
203
  }
177
204
 
178
- /**
179
- * Check health of the default or specified connection.
180
- * @param {string} [name]
181
- * @returns {Promise<object>}
182
- */
183
205
  async checkHealth(name) {
184
206
  try {
185
207
  const provider = this.getProvider(name);
@@ -201,3 +223,4 @@ export class StorageManager {
201
223
  }
202
224
 
203
225
  export const storageManager = new StorageManager();
226
+
@@ -5,148 +5,98 @@
5
5
  */
6
6
 
7
7
  import { StorageUnsupportedOperationError } from "./errors.js";
8
+ import { StorageCapabilities } from "./capabilities.js";
8
9
 
9
- /**
10
- * Abstract base class for storage providers (S3, Cloudflare R2, Google Drive, Azure, etc.)
11
- */
12
10
  export class StorageProvider {
13
- /**
14
- * @param {string} name - Connection/Bucket name identifier
15
- * @param {object} config - Provider-specific configuration
16
- */
17
11
  constructor(name, config = {}) {
18
12
  if (new.target === StorageProvider) {
19
13
  throw new TypeError("Cannot construct StorageProvider instances directly");
20
14
  }
21
- this.name = name;
15
+ this.name = String(name);
22
16
  this.config = Object.freeze({ ...config });
17
+ this.rootPrefix = config.rootPrefix || config.basePath || '';
23
18
  }
24
19
 
25
- /**
26
- * Provider type identifier (e.g. 's3', 'gdrive', 'r2')
27
- * @returns {string}
28
- */
29
20
  get type() {
30
21
  return 'generic';
31
22
  }
32
23
 
33
- /**
34
- * Explicit provider capabilities.
35
- * @returns {object}
36
- */
37
24
  get capabilities() {
38
- return {
25
+ return new StorageCapabilities({
39
26
  multipart: false,
40
- presignedUrls: false,
41
27
  nativeFolders: false,
42
28
  ranges: true,
43
29
  copy: false,
44
30
  move: false,
45
31
  search: false,
46
- directDownloadUrl: false
47
- };
32
+ directDownloadUrl: false,
33
+ versioning: false,
34
+ publicUrls: false,
35
+ metadata: true
36
+ });
37
+ }
38
+
39
+ supports(capabilityName) {
40
+ return this.capabilities.has(capabilityName);
41
+ }
42
+
43
+ assertCapability(capabilityName, operationName) {
44
+ this.capabilities.assert(capabilityName, operationName, `provider '${this.type}'`);
48
45
  }
49
46
 
50
- /**
51
- * List files and folders at the given prefix/path.
52
- * @param {string} [prefix='']
53
- * @param {object} [options={}]
54
- * @returns {Promise<import('./models.js').StorageListResult>}
55
- */
56
47
  async listFiles(prefix = '', options = {}) {
57
48
  throw new StorageUnsupportedOperationError(`listFiles() not implemented by ${this.constructor.name}`);
58
49
  }
59
50
 
60
- /**
61
- * Upload a file.
62
- * @param {string} key
63
- * @param {Buffer|Uint8Array|import('node:stream').Readable} fileBuffer
64
- * @param {string} contentType
65
- * @param {object} [options={}]
66
- * @returns {Promise<{ key: string, fileSize: number, contentType: string, uploadedAt: string, [key: string]: any }>}
67
- */
68
51
  async uploadFile(key, fileBuffer, contentType, options = {}) {
69
52
  throw new StorageUnsupportedOperationError(`uploadFile() not implemented by ${this.constructor.name}`);
70
53
  }
71
54
 
72
- /**
73
- * Download a file.
74
- * @param {string} key
75
- * @param {object} [options={}]
76
- * @returns {Promise<{ Body: import('node:stream').Readable, ContentType?: string, ContentLength?: number, ETag?: string, LastModified?: Date, notModified?: boolean, key: string }>}
77
- */
78
55
  async downloadFile(key, options = {}) {
79
56
  throw new StorageUnsupportedOperationError(`downloadFile() not implemented by ${this.constructor.name}`);
80
57
  }
81
58
 
82
- /**
83
- * Delete a single file.
84
- * @param {string} key
85
- * @param {object} [options={}]
86
- * @returns {Promise<{ key: string, deletedAt: string, [key: string]: any }>}
87
- */
88
59
  async deleteFile(key, options = {}) {
89
60
  throw new StorageUnsupportedOperationError(`deleteFile() not implemented by ${this.constructor.name}`);
90
61
  }
91
62
 
92
- /**
93
- * Delete multiple files.
94
- * @param {string[]} keys
95
- * @param {object} [options={}]
96
- * @returns {Promise<{ results: any[], deletedCount: number, errors: any[], deletedAt: string }>}
97
- */
98
63
  async deleteFiles(keys, options = {}) {
64
+ const concurrency = Math.max(1, Math.min(Number(options.concurrency) || 8, 32));
99
65
  let deletedCount = 0;
100
66
  const errors = [];
101
67
  const results = [];
102
- for (const key of keys) {
103
- try {
104
- const res = await this.deleteFile(key, options);
105
- results.push(res);
106
- deletedCount++;
107
- } catch (err) {
108
- errors.push({ key, error: err.message });
109
- }
68
+
69
+ for (let i = 0; i < keys.length; i += concurrency) {
70
+ const batch = keys.slice(i, i + concurrency);
71
+ const batchResults = await Promise.allSettled(
72
+ batch.map(key => this.deleteFile(key, options))
73
+ );
74
+ batchResults.forEach((outcome, idx) => {
75
+ const key = batch[idx];
76
+ if (outcome.status === 'fulfilled') {
77
+ results.push(outcome.value);
78
+ deletedCount++;
79
+ } else {
80
+ errors.push({ key, error: outcome.reason?.message || 'Delete failed' });
81
+ }
82
+ });
110
83
  }
84
+
111
85
  return { results, deletedCount, errors, deletedAt: new Date().toISOString() };
112
86
  }
113
87
 
114
- /**
115
- * Delete a folder and its contents.
116
- * @param {string} prefix
117
- * @param {object} [options={}]
118
- * @returns {Promise<{ deletedCount: number, prefix: string, deletedAt: string }>}
119
- */
120
88
  async deleteFolder(prefix, options = {}) {
121
89
  throw new StorageUnsupportedOperationError(`deleteFolder() not implemented by ${this.constructor.name}`);
122
90
  }
123
91
 
124
- /**
125
- * Create a folder.
126
- * @param {string} key
127
- * @param {object} [options={}]
128
- * @returns {Promise<{ key: string, success: boolean, [key: string]: any }>}
129
- */
130
92
  async createFolder(key, options = {}) {
131
93
  throw new StorageUnsupportedOperationError(`createFolder() not implemented by ${this.constructor.name}`);
132
94
  }
133
95
 
134
- /**
135
- * Get file metadata.
136
- * @param {string} key
137
- * @param {object} [options={}]
138
- * @returns {Promise<{ key: string, exists: boolean, ContentLength?: number, ContentType?: string, LastModified?: Date, ETag?: string, Metadata?: object, queriedAt: string }>}
139
- */
140
96
  async getFileMetadata(key, options = {}) {
141
97
  throw new StorageUnsupportedOperationError(`getFileMetadata() not implemented by ${this.constructor.name}`);
142
98
  }
143
99
 
144
- /**
145
- * Check if a file exists.
146
- * @param {string} key
147
- * @param {object} [options={}]
148
- * @returns {Promise<boolean>}
149
- */
150
100
  async fileExists(key, options = {}) {
151
101
  try {
152
102
  const meta = await this.getFileMetadata(key, options);
@@ -156,12 +106,6 @@ export class StorageProvider {
156
106
  }
157
107
  }
158
108
 
159
- /**
160
- * Get file size in bytes.
161
- * @param {string} key
162
- * @param {object} [options={}]
163
- * @returns {Promise<number|null>}
164
- */
165
109
  async getFileSize(key, options = {}) {
166
110
  try {
167
111
  const meta = await this.getFileMetadata(key, options);
@@ -171,47 +115,32 @@ export class StorageProvider {
171
115
  }
172
116
  }
173
117
 
174
- /**
175
- * Generate a signed or temporary access URL if supported.
176
- * @param {string} key
177
- * @param {'getObject'|'putObject'} [operation='getObject']
178
- * @param {number} [expiresIn=3600]
179
- * @param {object} [options={}]
180
- * @returns {Promise<{ url: string, key: string, operation: string, expiresIn: number, expiresAt: string, generatedAt: string }>}
181
- */
182
- async generateSignedUrl(key, operation = 'getObject', expiresIn = 3600, options = {}) {
183
- throw new StorageUnsupportedOperationError(`generateSignedUrl() is not supported by provider ${this.type}`);
184
- }
185
-
186
- /**
187
- * Copy a file.
188
- * @param {string} sourceKey
189
- * @param {string} destKey
190
- * @param {object} [options={}]
191
- * @returns {Promise<{ sourceKey: string, destKey: string, copiedAt: string }>}
192
- */
193
118
  async copyFile(sourceKey, destKey, options = {}) {
119
+ this.assertCapability('copy', 'copyFile');
194
120
  throw new StorageUnsupportedOperationError(`copyFile() is not supported by provider ${this.type}`);
195
121
  }
196
122
 
197
- /**
198
- * Move a file.
199
- * @param {string} sourceKey
200
- * @param {string} destKey
201
- * @param {object} [options={}]
202
- * @returns {Promise<{ sourceKey: string, destKey: string, movedAt: string }>}
203
- */
204
123
  async moveFile(sourceKey, destKey, options = {}) {
124
+ this.assertCapability('move', 'moveFile');
205
125
  await this.copyFile(sourceKey, destKey, options);
206
126
  await this.deleteFile(sourceKey, options);
207
127
  return { sourceKey, destKey, movedAt: new Date().toISOString() };
208
128
  }
209
129
 
210
- /**
211
- * Run health check.
212
- * @returns {Promise<{ status: 'healthy'|'unhealthy', responseTime?: number, error?: string, [key: string]: any }>}
213
- */
130
+ async initialize() {
131
+ return this;
132
+ }
133
+
134
+ async dispose() {
135
+ return this;
136
+ }
137
+
138
+ async ping() {
139
+ return this.checkHealth();
140
+ }
141
+
214
142
  async checkHealth() {
215
143
  throw new StorageUnsupportedOperationError(`checkHealth() not implemented by ${this.constructor.name}`);
216
144
  }
217
145
  }
146
+