@digibuffer/file-manager-core 1.0.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.
@@ -0,0 +1,1031 @@
1
+ import { createAwsSignature, createSignedUrl } from "@digibuffer/upload-lib-server/helpers";
2
+ import { z } from "zod";
3
+
4
+ //#region src/types.ts
5
+ /**
6
+ * Custom error class for file manager operations
7
+ */
8
+ var FileManagerError = class extends Error {
9
+ constructor(message, code, statusCode = 500, details) {
10
+ super(message);
11
+ this.code = code;
12
+ this.statusCode = statusCode;
13
+ this.details = details;
14
+ this.name = "FileManagerError";
15
+ }
16
+ };
17
+
18
+ //#endregion
19
+ //#region src/storage/s3-provider.ts
20
+ /**
21
+ * S3/R2 Storage Provider Implementation
22
+ */
23
+ var S3StorageProvider = class {
24
+ constructor(config) {
25
+ this.config = config;
26
+ }
27
+ /**
28
+ * List files from S3/R2 storage
29
+ */
30
+ async list(options = {}) {
31
+ const { prefix = "", delimiter = "", maxKeys = 1e3, cursor, sortBy = "lastModified", sortOrder = "desc" } = options;
32
+ const url = new URL(this.config.endpoint);
33
+ url.pathname = `/${this.config.bucket}`;
34
+ url.searchParams.set("list-type", "2");
35
+ url.searchParams.set("max-keys", maxKeys.toString());
36
+ if (prefix) url.searchParams.set("prefix", prefix);
37
+ if (delimiter) url.searchParams.set("delimiter", delimiter);
38
+ if (cursor?.continuationToken) url.searchParams.set("continuation-token", cursor.continuationToken);
39
+ const signature = await createAwsSignature({
40
+ method: "GET",
41
+ url: url.toString(),
42
+ region: this.config.region || "auto",
43
+ service: "s3",
44
+ accessKeyId: this.config.accessKeyId,
45
+ secretAccessKey: this.config.secretAccessKey
46
+ });
47
+ const response = await fetch(url.toString(), {
48
+ method: "GET",
49
+ headers: signature.headers
50
+ });
51
+ if (!response.ok) throw new FileManagerError(`Failed to list files: ${response.statusText}`, "LIST_ERROR", response.status);
52
+ const xmlText = await response.text();
53
+ const files = this.parseListResponse(xmlText);
54
+ const sortedFiles = this.sortFiles(files, sortBy, sortOrder);
55
+ const nextToken = this.extractContinuationToken(xmlText);
56
+ return {
57
+ data: sortedFiles,
58
+ nextCursor: nextToken ? { continuationToken: nextToken } : void 0,
59
+ hasMore: !!nextToken
60
+ };
61
+ }
62
+ /**
63
+ * Delete a single file
64
+ */
65
+ async delete(key) {
66
+ try {
67
+ const url = `${this.config.endpoint}/${this.config.bucket}/${encodeURIComponent(key)}`;
68
+ const signature = await createAwsSignature({
69
+ method: "DELETE",
70
+ url,
71
+ region: this.config.region || "auto",
72
+ service: "s3",
73
+ accessKeyId: this.config.accessKeyId,
74
+ secretAccessKey: this.config.secretAccessKey
75
+ });
76
+ const response = await fetch(url, {
77
+ method: "DELETE",
78
+ headers: signature.headers
79
+ });
80
+ if (!response.ok && response.status !== 404) throw new Error(`Failed to delete: ${response.statusText}`);
81
+ return {
82
+ success: true,
83
+ key
84
+ };
85
+ } catch (error) {
86
+ return {
87
+ success: false,
88
+ key,
89
+ error: error instanceof Error ? error.message : "Unknown error"
90
+ };
91
+ }
92
+ }
93
+ /**
94
+ * Delete multiple files in batch
95
+ */
96
+ async deleteBatch(keys) {
97
+ const results = await Promise.allSettled(keys.map((key) => this.delete(key)));
98
+ const succeeded = [];
99
+ const failed = [];
100
+ results.forEach((result, index) => {
101
+ if (result.status === "fulfilled" && result.value.success) succeeded.push(keys[index]);
102
+ else failed.push({
103
+ key: keys[index],
104
+ error: result.status === "rejected" ? result.reason : result.value.error || "Unknown error"
105
+ });
106
+ });
107
+ return {
108
+ succeeded,
109
+ failed,
110
+ totalProcessed: keys.length
111
+ };
112
+ }
113
+ /**
114
+ * Check if file exists
115
+ */
116
+ async exists(key) {
117
+ try {
118
+ return await this.getMetadata(key) !== null;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+ /**
124
+ * Get file metadata
125
+ */
126
+ async getMetadata(key) {
127
+ try {
128
+ const url = `${this.config.endpoint}/${this.config.bucket}/${encodeURIComponent(key)}`;
129
+ const signature = await createAwsSignature({
130
+ method: "HEAD",
131
+ url,
132
+ region: this.config.region || "auto",
133
+ service: "s3",
134
+ accessKeyId: this.config.accessKeyId,
135
+ secretAccessKey: this.config.secretAccessKey
136
+ });
137
+ const response = await fetch(url, {
138
+ method: "HEAD",
139
+ headers: signature.headers
140
+ });
141
+ if (!response.ok) {
142
+ if (response.status === 404) return null;
143
+ throw new Error(`Failed to get metadata: ${response.statusText}`);
144
+ }
145
+ return {
146
+ key,
147
+ bucket: this.config.bucket,
148
+ size: parseInt(response.headers.get("content-length") || "0", 10),
149
+ lastModified: new Date(response.headers.get("last-modified") || Date.now()),
150
+ contentType: response.headers.get("content-type") || void 0,
151
+ etag: response.headers.get("etag")?.replace(/"/g, "") || void 0
152
+ };
153
+ } catch (error) {
154
+ console.error(`Error getting metadata for ${key}:`, error);
155
+ return null;
156
+ }
157
+ }
158
+ /**
159
+ * Generate presigned download URL
160
+ */
161
+ async getDownloadUrl(key, options = {}) {
162
+ const { expiresIn = 3600, disposition = "inline", filename } = options;
163
+ const url = new URL(`${this.config.endpoint}/${this.config.bucket}/${encodeURIComponent(key)}`);
164
+ if (disposition === "attachment") {
165
+ const downloadFilename = filename || key.split("/").pop() || "download";
166
+ url.searchParams.set("response-content-disposition", `attachment; filename="${downloadFilename}"`);
167
+ }
168
+ return await createSignedUrl({
169
+ url: url.toString(),
170
+ method: "GET",
171
+ expiresIn,
172
+ region: this.config.region || "auto",
173
+ service: "s3",
174
+ accessKeyId: this.config.accessKeyId,
175
+ secretAccessKey: this.config.secretAccessKey
176
+ });
177
+ }
178
+ /**
179
+ * Move a file (copy + delete source)
180
+ */
181
+ async move(options) {
182
+ const { sourceKey, destinationKey } = options;
183
+ try {
184
+ const copyResult = await this.copy({
185
+ sourceKey,
186
+ destinationKey
187
+ });
188
+ if (!copyResult.success) return copyResult;
189
+ const deleteResult = await this.delete(sourceKey);
190
+ if (!deleteResult.success) {
191
+ await this.delete(destinationKey);
192
+ return {
193
+ success: false,
194
+ key: sourceKey,
195
+ error: `Failed to delete source after copy: ${deleteResult.error}`
196
+ };
197
+ }
198
+ return {
199
+ success: true,
200
+ key: destinationKey
201
+ };
202
+ } catch (error) {
203
+ return {
204
+ success: false,
205
+ key: sourceKey,
206
+ error: error instanceof Error ? error.message : "Unknown error"
207
+ };
208
+ }
209
+ }
210
+ /**
211
+ * Copy a file to a new location
212
+ */
213
+ async copy(options) {
214
+ const { sourceKey, destinationKey, metadata } = options;
215
+ try {
216
+ const url = `${this.config.endpoint}/${this.config.bucket}/${encodeURIComponent(destinationKey)}`;
217
+ const headers = { "x-amz-copy-source": `/${this.config.bucket}/${encodeURIComponent(sourceKey)}` };
218
+ if (metadata) {
219
+ headers["x-amz-metadata-directive"] = "REPLACE";
220
+ Object.entries(metadata).forEach(([key, value]) => {
221
+ headers[`x-amz-meta-${key}`] = value;
222
+ });
223
+ }
224
+ const signature = await createAwsSignature({
225
+ method: "PUT",
226
+ url,
227
+ region: this.config.region || "auto",
228
+ service: "s3",
229
+ accessKeyId: this.config.accessKeyId,
230
+ secretAccessKey: this.config.secretAccessKey,
231
+ headers
232
+ });
233
+ const response = await fetch(url, {
234
+ method: "PUT",
235
+ headers: {
236
+ ...signature.headers,
237
+ ...headers
238
+ }
239
+ });
240
+ if (!response.ok) throw new Error(`Failed to copy: ${response.statusText}`);
241
+ return {
242
+ success: true,
243
+ key: destinationKey
244
+ };
245
+ } catch (error) {
246
+ return {
247
+ success: false,
248
+ key: sourceKey,
249
+ error: error instanceof Error ? error.message : "Unknown error"
250
+ };
251
+ }
252
+ }
253
+ /**
254
+ * Rename a file (same as move)
255
+ */
256
+ async rename(oldKey, newKey) {
257
+ return this.move({
258
+ sourceKey: oldKey,
259
+ destinationKey: newKey
260
+ });
261
+ }
262
+ /**
263
+ * Parse XML list response
264
+ */
265
+ parseListResponse(xmlText) {
266
+ const files = [];
267
+ const contentsRegex = /<Contents>([\s\S]*?)<\/Contents>/g;
268
+ let match;
269
+ while ((match = contentsRegex.exec(xmlText)) !== null) {
270
+ const content = match[1];
271
+ const key = this.extractXmlValue(content, "Key");
272
+ const size = this.extractXmlValue(content, "Size");
273
+ const lastModified = this.extractXmlValue(content, "LastModified");
274
+ const etag = this.extractXmlValue(content, "ETag");
275
+ if (key) files.push({
276
+ key,
277
+ bucket: this.config.bucket,
278
+ size: parseInt(size || "0", 10),
279
+ lastModified: new Date(lastModified || Date.now()),
280
+ etag: etag?.replace(/"/g, "")
281
+ });
282
+ }
283
+ return files;
284
+ }
285
+ /**
286
+ * Extract continuation token from XML
287
+ */
288
+ extractContinuationToken(xmlText) {
289
+ return this.extractXmlValue(xmlText, "NextContinuationToken") || void 0;
290
+ }
291
+ /**
292
+ * Extract value from XML tag
293
+ */
294
+ extractXmlValue(xml, tag) {
295
+ const regex = /* @__PURE__ */ new RegExp(`<${tag}>([^<]*)<\/${tag}>`);
296
+ const match = xml.match(regex);
297
+ return match ? match[1] : null;
298
+ }
299
+ /**
300
+ * Sort files by specified field
301
+ */
302
+ sortFiles(files, sortBy, sortOrder) {
303
+ return files.sort((a, b) => {
304
+ let comparison = 0;
305
+ switch (sortBy) {
306
+ case "name":
307
+ comparison = a.key.localeCompare(b.key);
308
+ break;
309
+ case "size":
310
+ comparison = a.size - b.size;
311
+ break;
312
+ case "lastModified":
313
+ comparison = a.lastModified.getTime() - b.lastModified.getTime();
314
+ break;
315
+ }
316
+ return sortOrder === "asc" ? comparison : -comparison;
317
+ });
318
+ }
319
+ };
320
+
321
+ //#endregion
322
+ //#region src/database/postgres-provider.ts
323
+ /**
324
+ * PostgreSQL Database Provider Implementation
325
+ */
326
+ var PostgresDatabaseProvider = class {
327
+ sql;
328
+ tableName;
329
+ constructor(config) {
330
+ this.tableName = config.tableName || "files";
331
+ this.initializeConnection(config);
332
+ }
333
+ /**
334
+ * Initialize PostgreSQL connection
335
+ */
336
+ async initializeConnection(config) {
337
+ try {
338
+ const postgres = await import("postgres");
339
+ if (config.connectionString) this.sql = postgres.default(config.connectionString);
340
+ else this.sql = postgres.default({
341
+ host: config.host,
342
+ port: config.port || 5432,
343
+ database: config.database,
344
+ user: config.user,
345
+ password: config.password
346
+ });
347
+ await this.ensureTableExists();
348
+ } catch (error) {
349
+ if (error.code === "ERR_MODULE_NOT_FOUND") throw new FileManagerError("PostgreSQL dependency not found. Install \"postgres\" package to use database mode.", "MISSING_DEPENDENCY", 500);
350
+ throw error;
351
+ }
352
+ }
353
+ /**
354
+ * Create files table if it doesn't exist
355
+ */
356
+ async ensureTableExists() {
357
+ await this.sql`
358
+ CREATE TABLE IF NOT EXISTS ${this.sql(this.tableName)} (
359
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
360
+ key TEXT NOT NULL UNIQUE,
361
+ size BIGINT NOT NULL,
362
+ content_type TEXT,
363
+ etag TEXT,
364
+ user_id TEXT,
365
+ custom_metadata JSONB,
366
+ last_modified TIMESTAMP NOT NULL,
367
+ uploaded_at TIMESTAMP NOT NULL DEFAULT NOW(),
368
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
369
+ )
370
+ `;
371
+ await this.sql`
372
+ CREATE INDEX IF NOT EXISTS ${this.sql(`idx_${this.tableName}_key`)}
373
+ ON ${this.sql(this.tableName)} (key)
374
+ `;
375
+ await this.sql`
376
+ CREATE INDEX IF NOT EXISTS ${this.sql(`idx_${this.tableName}_user_id`)}
377
+ ON ${this.sql(this.tableName)} (user_id)
378
+ `;
379
+ await this.sql`
380
+ CREATE INDEX IF NOT EXISTS ${this.sql(`idx_${this.tableName}_uploaded_at`)}
381
+ ON ${this.sql(this.tableName)} (uploaded_at DESC)
382
+ `;
383
+ }
384
+ /**
385
+ * List files from database
386
+ */
387
+ async list(options = {}) {
388
+ const { prefix = "", maxKeys = 1e3, cursor, sortBy = "lastModified", sortOrder = "desc", userId } = options;
389
+ const offset = cursor?.offset || 0;
390
+ let query = this.sql`
391
+ SELECT
392
+ id::text,
393
+ key,
394
+ size,
395
+ content_type,
396
+ etag,
397
+ user_id,
398
+ custom_metadata,
399
+ last_modified,
400
+ uploaded_at,
401
+ updated_at
402
+ FROM ${this.sql(this.tableName)}
403
+ WHERE 1=1
404
+ `;
405
+ if (prefix) query = this.sql`${query} AND key LIKE ${prefix + "%"}`;
406
+ if (userId) query = this.sql`${query} AND user_id = ${userId}`;
407
+ const sortColumn = this.getSortColumn(sortBy);
408
+ const order = sortOrder.toUpperCase();
409
+ query = this.sql`${query} ORDER BY ${this.sql(sortColumn)} ${this.sql.unsafe(order)}`;
410
+ query = this.sql`${query} LIMIT ${maxKeys + 1} OFFSET ${offset}`;
411
+ const rows = await query;
412
+ const hasMore = rows.length > maxKeys;
413
+ return {
414
+ data: (hasMore ? rows.slice(0, maxKeys) : rows).map((row) => ({
415
+ id: row.id,
416
+ key: row.key,
417
+ size: Number(row.size),
418
+ contentType: row.content_type,
419
+ etag: row.etag,
420
+ userId: row.user_id,
421
+ customMetadata: row.custom_metadata,
422
+ lastModified: new Date(row.last_modified),
423
+ uploadedAt: new Date(row.uploaded_at),
424
+ updatedAt: new Date(row.updated_at)
425
+ })),
426
+ nextCursor: hasMore ? { offset: offset + maxKeys } : void 0,
427
+ hasMore
428
+ };
429
+ }
430
+ /**
431
+ * Get a single file by key
432
+ */
433
+ async get(key) {
434
+ const rows = await this.sql`
435
+ SELECT
436
+ id::text,
437
+ key,
438
+ size,
439
+ content_type,
440
+ etag,
441
+ user_id,
442
+ custom_metadata,
443
+ last_modified,
444
+ uploaded_at,
445
+ updated_at
446
+ FROM ${this.sql(this.tableName)}
447
+ WHERE key = ${key}
448
+ LIMIT 1
449
+ `;
450
+ if (rows.length === 0) return null;
451
+ const row = rows[0];
452
+ return {
453
+ id: row.id,
454
+ key: row.key,
455
+ size: Number(row.size),
456
+ contentType: row.content_type,
457
+ etag: row.etag,
458
+ userId: row.user_id,
459
+ customMetadata: row.custom_metadata,
460
+ lastModified: new Date(row.last_modified),
461
+ uploadedAt: new Date(row.uploaded_at),
462
+ updatedAt: new Date(row.updated_at)
463
+ };
464
+ }
465
+ /**
466
+ * Create a new file record
467
+ */
468
+ async create(file) {
469
+ const row = (await this.sql`
470
+ INSERT INTO ${this.sql(this.tableName)} (
471
+ key,
472
+ size,
473
+ content_type,
474
+ etag,
475
+ user_id,
476
+ custom_metadata,
477
+ last_modified
478
+ )
479
+ VALUES (
480
+ ${file.key},
481
+ ${file.size},
482
+ ${file.contentType || null},
483
+ ${file.etag || null},
484
+ ${file.userId || null},
485
+ ${file.customMetadata ? JSON.stringify(file.customMetadata) : null},
486
+ ${file.lastModified}
487
+ )
488
+ ON CONFLICT (key) DO UPDATE SET
489
+ size = EXCLUDED.size,
490
+ content_type = EXCLUDED.content_type,
491
+ etag = EXCLUDED.etag,
492
+ last_modified = EXCLUDED.last_modified,
493
+ updated_at = NOW()
494
+ RETURNING
495
+ id::text,
496
+ key,
497
+ size,
498
+ content_type,
499
+ etag,
500
+ user_id,
501
+ custom_metadata,
502
+ last_modified,
503
+ uploaded_at,
504
+ updated_at
505
+ `)[0];
506
+ return {
507
+ id: row.id,
508
+ key: row.key,
509
+ size: Number(row.size),
510
+ contentType: row.content_type,
511
+ etag: row.etag,
512
+ userId: row.user_id,
513
+ customMetadata: row.custom_metadata,
514
+ lastModified: new Date(row.last_modified),
515
+ uploadedAt: new Date(row.uploaded_at),
516
+ updatedAt: new Date(row.updated_at)
517
+ };
518
+ }
519
+ /**
520
+ * Update file metadata
521
+ */
522
+ async update(key, data) {
523
+ const updates = { updated_at: /* @__PURE__ */ new Date() };
524
+ if (data.size !== void 0) updates.size = data.size;
525
+ if (data.contentType !== void 0) updates.content_type = data.contentType;
526
+ if (data.etag !== void 0) updates.etag = data.etag;
527
+ if (data.userId !== void 0) updates.user_id = data.userId;
528
+ if (data.customMetadata !== void 0) updates.custom_metadata = JSON.stringify(data.customMetadata);
529
+ if (data.lastModified !== void 0) updates.last_modified = data.lastModified;
530
+ const rows = await this.sql`
531
+ UPDATE ${this.sql(this.tableName)}
532
+ SET ${this.sql(updates)}
533
+ WHERE key = ${key}
534
+ RETURNING
535
+ id::text,
536
+ key,
537
+ size,
538
+ content_type,
539
+ etag,
540
+ user_id,
541
+ custom_metadata,
542
+ last_modified,
543
+ uploaded_at,
544
+ updated_at
545
+ `;
546
+ if (rows.length === 0) return null;
547
+ const row = rows[0];
548
+ return {
549
+ id: row.id,
550
+ key: row.key,
551
+ size: Number(row.size),
552
+ contentType: row.content_type,
553
+ etag: row.etag,
554
+ userId: row.user_id,
555
+ customMetadata: row.custom_metadata,
556
+ lastModified: new Date(row.last_modified),
557
+ uploadedAt: new Date(row.uploaded_at),
558
+ updatedAt: new Date(row.updated_at)
559
+ };
560
+ }
561
+ /**
562
+ * Delete a file record
563
+ */
564
+ async delete(key) {
565
+ try {
566
+ await this.sql`
567
+ DELETE FROM ${this.sql(this.tableName)}
568
+ WHERE key = ${key}
569
+ `;
570
+ return {
571
+ success: true,
572
+ key
573
+ };
574
+ } catch (error) {
575
+ return {
576
+ success: false,
577
+ key,
578
+ error: error instanceof Error ? error.message : "Unknown error"
579
+ };
580
+ }
581
+ }
582
+ /**
583
+ * Delete multiple file records
584
+ */
585
+ async deleteBatch(keys) {
586
+ try {
587
+ const deleted = (await this.sql`
588
+ DELETE FROM ${this.sql(this.tableName)}
589
+ WHERE key = ANY(${keys})
590
+ RETURNING key
591
+ `).map((row) => row.key);
592
+ return {
593
+ succeeded: deleted,
594
+ failed: keys.filter((key) => !deleted.includes(key)).map((key) => ({
595
+ key,
596
+ error: "Not found"
597
+ })),
598
+ totalProcessed: keys.length
599
+ };
600
+ } catch (error) {
601
+ return {
602
+ succeeded: [],
603
+ failed: keys.map((key) => ({
604
+ key,
605
+ error: error instanceof Error ? error.message : "Unknown error"
606
+ })),
607
+ totalProcessed: keys.length
608
+ };
609
+ }
610
+ }
611
+ /**
612
+ * Check if file exists
613
+ */
614
+ async exists(key) {
615
+ return (await this.sql`
616
+ SELECT 1 FROM ${this.sql(this.tableName)}
617
+ WHERE key = ${key}
618
+ LIMIT 1
619
+ `).length > 0;
620
+ }
621
+ /**
622
+ * Sync storage files to database
623
+ */
624
+ async sync(storageFiles) {
625
+ if (storageFiles.length === 0) return;
626
+ const values = storageFiles.map((file) => ({
627
+ key: file.key,
628
+ size: file.size,
629
+ content_type: file.contentType || null,
630
+ etag: file.etag || null,
631
+ last_modified: file.lastModified
632
+ }));
633
+ await this.sql`
634
+ INSERT INTO ${this.sql(this.tableName)} ${this.sql(values)}
635
+ ON CONFLICT (key) DO UPDATE SET
636
+ size = EXCLUDED.size,
637
+ content_type = EXCLUDED.content_type,
638
+ etag = EXCLUDED.etag,
639
+ last_modified = EXCLUDED.last_modified,
640
+ updated_at = NOW()
641
+ `;
642
+ }
643
+ /**
644
+ * Get sort column for SQL query
645
+ */
646
+ getSortColumn(sortBy) {
647
+ switch (sortBy) {
648
+ case "name": return "key";
649
+ case "size": return "size";
650
+ case "lastModified": return "last_modified";
651
+ default: return "last_modified";
652
+ }
653
+ }
654
+ /**
655
+ * Close database connection
656
+ */
657
+ async close() {
658
+ if (this.sql) await this.sql.end();
659
+ }
660
+ };
661
+
662
+ //#endregion
663
+ //#region src/file-manager.ts
664
+ /**
665
+ * Main FileManager class - Orchestrates file operations across storage and database
666
+ */
667
+ var FileManager = class {
668
+ storage;
669
+ database;
670
+ defaultSource;
671
+ autoSync;
672
+ hooks;
673
+ constructor(config, hooks) {
674
+ this.storage = new S3StorageProvider(config.storage);
675
+ this.database = config.database ? new PostgresDatabaseProvider(config.database) : void 0;
676
+ this.defaultSource = config.defaultSource || (this.database ? "database" : "storage");
677
+ this.autoSync = config.autoSync ?? true;
678
+ this.hooks = hooks;
679
+ }
680
+ /**
681
+ * List files from specified source
682
+ */
683
+ async list(options = {}, source) {
684
+ const dataSource = source || this.defaultSource;
685
+ switch (dataSource) {
686
+ case "storage": return this.listFromStorage(options);
687
+ case "database":
688
+ if (!this.database) throw new FileManagerError("Database not configured", "DATABASE_NOT_CONFIGURED", 400);
689
+ return this.listFromDatabase(options);
690
+ case "both": return this.listFromBoth(options);
691
+ default: throw new FileManagerError(`Invalid data source: ${dataSource}`, "INVALID_DATA_SOURCE", 400);
692
+ }
693
+ }
694
+ /**
695
+ * List files from storage only
696
+ */
697
+ async listFromStorage(options = {}) {
698
+ return this.storage.list(options);
699
+ }
700
+ /**
701
+ * List files from database only
702
+ */
703
+ async listFromDatabase(options = {}) {
704
+ if (!this.database) throw new FileManagerError("Database not configured", "DATABASE_NOT_CONFIGURED", 400);
705
+ return this.database.list(options);
706
+ }
707
+ /**
708
+ * List files from both storage and database, merged
709
+ */
710
+ async listFromBoth(options = {}) {
711
+ if (!this.database) return this.listFromStorage(options);
712
+ const [storageResult, dbResult] = await Promise.all([this.storage.list(options), this.database.list(options)]);
713
+ const dbKeys = new Set(dbResult.data.map((f) => f.key));
714
+ return {
715
+ data: [...dbResult.data, ...storageResult.data.filter((f) => !dbKeys.has(f.key))],
716
+ nextCursor: dbResult.nextCursor || storageResult.nextCursor,
717
+ hasMore: dbResult.hasMore || storageResult.hasMore
718
+ };
719
+ }
720
+ /**
721
+ * Get file metadata
722
+ */
723
+ async getMetadata(key, source) {
724
+ if ((source || this.defaultSource) === "database" && this.database) return this.database.get(key);
725
+ return this.storage.getMetadata(key);
726
+ }
727
+ /**
728
+ * Check if file exists
729
+ */
730
+ async exists(key, source) {
731
+ if ((source || this.defaultSource) === "database" && this.database) return this.database.exists(key);
732
+ return this.storage.exists(key);
733
+ }
734
+ /**
735
+ * Get download URL for a file
736
+ */
737
+ async getDownloadUrl(key, options) {
738
+ return this.storage.getDownloadUrl(key, options);
739
+ }
740
+ /**
741
+ * Delete a single file
742
+ */
743
+ async delete(key) {
744
+ if (this.hooks?.beforeDelete) await this.hooks.beforeDelete([key]);
745
+ const storageResult = await this.storage.delete(key);
746
+ if (this.database && storageResult.success) await this.database.delete(key);
747
+ if (this.hooks?.afterDelete) await this.hooks.afterDelete([key]);
748
+ return storageResult;
749
+ }
750
+ /**
751
+ * Delete multiple files in batch
752
+ */
753
+ async deleteBatch(keys) {
754
+ if (this.hooks?.beforeDelete) await this.hooks.beforeDelete(keys);
755
+ const storageResult = await this.storage.deleteBatch(keys);
756
+ if (this.database && storageResult.succeeded.length > 0) await this.database.deleteBatch(storageResult.succeeded);
757
+ if (this.hooks?.afterDelete) await this.hooks.afterDelete(storageResult.succeeded);
758
+ return storageResult;
759
+ }
760
+ /**
761
+ * Move a file to a new location
762
+ */
763
+ async move(options) {
764
+ if (this.hooks?.beforeMove) await this.hooks.beforeMove(options);
765
+ const result = await this.storage.move(options);
766
+ if (this.database && result.success) {
767
+ const dbFile = await this.database.get(options.sourceKey);
768
+ if (dbFile) {
769
+ await this.database.delete(options.sourceKey);
770
+ await this.database.create({
771
+ ...dbFile,
772
+ key: options.destinationKey
773
+ });
774
+ } else if (this.autoSync) {
775
+ const metadata = await this.storage.getMetadata(options.destinationKey);
776
+ if (metadata) await this.database.create({
777
+ key: metadata.key,
778
+ size: metadata.size,
779
+ lastModified: metadata.lastModified,
780
+ contentType: metadata.contentType,
781
+ etag: metadata.etag
782
+ });
783
+ }
784
+ }
785
+ if (this.hooks?.afterMove) await this.hooks.afterMove(options);
786
+ return result;
787
+ }
788
+ /**
789
+ * Copy a file to a new location
790
+ */
791
+ async copy(options) {
792
+ if (this.hooks?.beforeCopy) await this.hooks.beforeCopy(options);
793
+ const result = await this.storage.copy(options);
794
+ if (this.database && result.success) {
795
+ const sourceFile = await this.database.get(options.sourceKey);
796
+ if (sourceFile) await this.database.create({
797
+ ...sourceFile,
798
+ key: options.destinationKey
799
+ });
800
+ else if (this.autoSync) {
801
+ const metadata = await this.storage.getMetadata(options.destinationKey);
802
+ if (metadata) await this.database.create({
803
+ key: metadata.key,
804
+ size: metadata.size,
805
+ lastModified: metadata.lastModified,
806
+ contentType: metadata.contentType,
807
+ etag: metadata.etag
808
+ });
809
+ }
810
+ }
811
+ if (this.hooks?.afterCopy) await this.hooks.afterCopy(options);
812
+ return result;
813
+ }
814
+ /**
815
+ * Rename a file (alias for move)
816
+ */
817
+ async rename(oldKey, newKey) {
818
+ return this.move({
819
+ sourceKey: oldKey,
820
+ destinationKey: newKey
821
+ });
822
+ }
823
+ /**
824
+ * Sync storage files to database
825
+ */
826
+ async syncToDatabase(options = {}) {
827
+ if (!this.database) throw new FileManagerError("Database not configured", "DATABASE_NOT_CONFIGURED", 400);
828
+ let hasMore = true;
829
+ let cursor = options.cursor;
830
+ while (hasMore) {
831
+ const result = await this.storage.list({
832
+ ...options,
833
+ cursor
834
+ });
835
+ await this.database.sync(result.data);
836
+ hasMore = result.hasMore;
837
+ cursor = result.nextCursor;
838
+ }
839
+ }
840
+ /**
841
+ * Create a file record in database (useful after upload)
842
+ */
843
+ async createDatabaseRecord(file) {
844
+ if (!this.database) return null;
845
+ return this.database.create(file);
846
+ }
847
+ /**
848
+ * Update file metadata in database
849
+ */
850
+ async updateDatabaseRecord(key, data) {
851
+ if (!this.database) return null;
852
+ return this.database.update(key, data);
853
+ }
854
+ };
855
+
856
+ //#endregion
857
+ //#region src/router.ts
858
+ /**
859
+ * Request validation schemas
860
+ */
861
+ const ListOptionsSchema = z.object({
862
+ prefix: z.string().optional(),
863
+ delimiter: z.string().optional(),
864
+ maxKeys: z.number().min(1).max(1e4).optional(),
865
+ cursor: z.object({
866
+ continuationToken: z.string().optional(),
867
+ offset: z.number().optional()
868
+ }).optional(),
869
+ sortBy: z.enum([
870
+ "name",
871
+ "size",
872
+ "lastModified"
873
+ ]).optional(),
874
+ sortOrder: z.enum(["asc", "desc"]).optional(),
875
+ userId: z.string().optional()
876
+ });
877
+ const DownloadUrlOptionsSchema = z.object({
878
+ expiresIn: z.number().optional(),
879
+ disposition: z.enum(["inline", "attachment"]).optional(),
880
+ filename: z.string().optional()
881
+ });
882
+ const MoveOptionsSchema = z.object({
883
+ sourceKey: z.string().min(1),
884
+ destinationKey: z.string().min(1),
885
+ deleteSource: z.boolean().optional()
886
+ });
887
+ const CopyOptionsSchema = z.object({
888
+ sourceKey: z.string().min(1),
889
+ destinationKey: z.string().min(1),
890
+ metadata: z.record(z.string()).optional()
891
+ });
892
+ const RenameOptionsSchema = z.object({ newKey: z.string().min(1) });
893
+ const FileOperationRequestSchema = z.object({
894
+ action: z.enum([
895
+ "list",
896
+ "delete",
897
+ "batchDelete",
898
+ "exists",
899
+ "getMetadata",
900
+ "getUrl",
901
+ "move",
902
+ "copy",
903
+ "rename",
904
+ "createFolder"
905
+ ]),
906
+ key: z.string().optional(),
907
+ keys: z.array(z.string()).optional(),
908
+ options: z.record(z.string(), z.unknown()).optional(),
909
+ source: z.enum([
910
+ "storage",
911
+ "database",
912
+ "both"
913
+ ]).optional()
914
+ });
915
+ /**
916
+ * FileManager Router - Handles HTTP requests for file operations
917
+ */
918
+ var FileManagerRouter = class {
919
+ fileManager;
920
+ authorize;
921
+ getAuthContext;
922
+ constructor(config) {
923
+ this.fileManager = config.fileManager;
924
+ this.authorize = config.authorize;
925
+ this.getAuthContext = config.getAuthContext;
926
+ }
927
+ /**
928
+ * Handle incoming request
929
+ */
930
+ async handle(request) {
931
+ try {
932
+ const body = await request.json();
933
+ const validatedRequest = FileOperationRequestSchema.parse(body);
934
+ const authContext = this.getAuthContext ? await this.getAuthContext(request) : {};
935
+ if (this.authorize) {
936
+ if (!await this.authorize(authContext, validatedRequest.action, validatedRequest.key || "batch")) return this.errorResponse("Unauthorized", "UNAUTHORIZED", 403);
937
+ }
938
+ const result = await this.routeAction(validatedRequest, authContext);
939
+ return this.successResponse(result);
940
+ } catch (error) {
941
+ return this.handleError(error);
942
+ }
943
+ }
944
+ /**
945
+ * Route action to appropriate handler
946
+ */
947
+ async routeAction(request, authContext) {
948
+ const { action, key, keys, options, source } = request;
949
+ switch (action) {
950
+ case "list": return this.fileManager.list(ListOptionsSchema.parse(options ?? {}), source);
951
+ case "delete":
952
+ if (!key) throw new FileManagerError("Key is required for delete", "MISSING_KEY", 400);
953
+ return this.fileManager.delete(key);
954
+ case "batchDelete":
955
+ if (!keys || keys.length === 0) throw new FileManagerError("Keys are required for batchDelete", "MISSING_KEYS", 400);
956
+ return this.fileManager.deleteBatch(keys);
957
+ case "exists":
958
+ if (!key) throw new FileManagerError("Key is required for exists", "MISSING_KEY", 400);
959
+ return { exists: await this.fileManager.exists(key, source) };
960
+ case "getMetadata":
961
+ if (!key) throw new FileManagerError("Key is required for getMetadata", "MISSING_KEY", 400);
962
+ return this.fileManager.getMetadata(key, source);
963
+ case "getUrl":
964
+ if (!key) throw new FileManagerError("Key is required for getUrl", "MISSING_KEY", 400);
965
+ return { url: await this.fileManager.getDownloadUrl(key, DownloadUrlOptionsSchema.parse(options ?? {})) };
966
+ case "move":
967
+ const moveOpts = MoveOptionsSchema.parse(options);
968
+ return this.fileManager.move(moveOpts);
969
+ case "copy":
970
+ const copyOpts = CopyOptionsSchema.parse(options);
971
+ return this.fileManager.copy(copyOpts);
972
+ case "rename":
973
+ if (!key) throw new FileManagerError("Key is required for rename", "MISSING_KEY", 400);
974
+ const renameOpts = RenameOptionsSchema.parse(options);
975
+ return this.fileManager.rename(key, renameOpts.newKey);
976
+ case "createFolder":
977
+ if (!key) throw new FileManagerError("Key is required for createFolder", "MISSING_KEY", 400);
978
+ return {
979
+ success: true,
980
+ key,
981
+ message: "Folder created"
982
+ };
983
+ default: throw new FileManagerError(`Unknown action: ${action}`, "UNKNOWN_ACTION", 400);
984
+ }
985
+ }
986
+ /**
987
+ * Success response
988
+ */
989
+ successResponse(data) {
990
+ const response = {
991
+ success: true,
992
+ data
993
+ };
994
+ return new Response(JSON.stringify(response), {
995
+ status: 200,
996
+ headers: { "Content-Type": "application/json" }
997
+ });
998
+ }
999
+ /**
1000
+ * Error response
1001
+ */
1002
+ errorResponse(message, code, status) {
1003
+ const response = {
1004
+ success: false,
1005
+ error: message,
1006
+ code
1007
+ };
1008
+ return new Response(JSON.stringify(response), {
1009
+ status,
1010
+ headers: { "Content-Type": "application/json" }
1011
+ });
1012
+ }
1013
+ /**
1014
+ * Handle errors
1015
+ */
1016
+ handleError(error) {
1017
+ console.error("FileManager Router Error:", error);
1018
+ if (error instanceof FileManagerError) return this.errorResponse(error.message, error.code, error.statusCode);
1019
+ if (error instanceof z.ZodError) return this.errorResponse(`Validation error: ${error.errors.map((e) => e.message).join(", ")}`, "VALIDATION_ERROR", 400);
1020
+ return this.errorResponse("Internal server error", "INTERNAL_ERROR", 500);
1021
+ }
1022
+ };
1023
+ /**
1024
+ * Create a new FileManager router instance
1025
+ */
1026
+ function createFileManagerRouter(config) {
1027
+ return new FileManagerRouter(config);
1028
+ }
1029
+
1030
+ //#endregion
1031
+ export { S3StorageProvider as a, PostgresDatabaseProvider as i, createFileManagerRouter as n, FileManagerError as o, FileManager as r, FileManagerRouter as t };