UNPKG

@profullstack/storage-service

Version:

Flexible storage service abstraction for file storage, retrieval, and metadata management

732 lines (628 loc) 21.1 kB
/** * In-Memory Storage Adapter for Storage Service * * This adapter stores files in memory, suitable for development or testing. * For production use, consider using the Filesystem, S3, or Supabase adapters. */ import { v4 as uuidv4 } from 'uuid'; /** * In-Memory Storage Adapter */ export class MemoryAdapter { /** * Create a new Memory Adapter */ constructor() { // Initialize storage this.buckets = new Map(); this.files = new Map(); this.metadata = new Map(); } /** * Create a bucket * @param {string} bucketName - Bucket name * @param {Object} options - Bucket options * @returns {Promise<Object>} - Bucket information */ async createBucket(bucketName, options = {}) { // Check if bucket already exists if (this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" already exists`); } // Create bucket const bucket = { name: bucketName, createdAt: new Date().toISOString(), ...options }; // Store bucket this.buckets.set(bucketName, bucket); this.files.set(bucketName, new Map()); return { ...bucket }; } /** * List all buckets * @returns {Promise<Array>} - List of buckets */ async listBuckets() { return Array.from(this.buckets.values()).map(bucket => ({ ...bucket })); } /** * Delete a bucket * @param {string} bucketName - Bucket name * @param {Object} options - Delete options * @param {boolean} options.force - Whether to force deletion even if the bucket is not empty * @returns {Promise<boolean>} - Whether the bucket was deleted */ async deleteBucket(bucketName, options = {}) { // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Check if bucket is empty const bucketFiles = this.files.get(bucketName); if (bucketFiles.size > 0 && !options.force) { throw new Error(`Bucket "${bucketName}" is not empty`); } // Delete bucket this.buckets.delete(bucketName); this.files.delete(bucketName); // Delete all files in bucket from metadata for (const [key] of this.metadata.entries()) { if (key.startsWith(`${bucketName}:`)) { this.metadata.delete(key); } } return true; } /** * Upload a file * @param {Object} options - Upload options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @param {Buffer|Blob|string} options.data - File data * @param {string} options.contentType - Content type * @param {Object} options.metadata - File metadata * @param {boolean} options.upsert - Whether to overwrite existing file * @param {boolean} options.public - Whether the file is public * @returns {Promise<Object>} - Upload result */ async uploadFile(options) { const { bucketName, path: filePath, data, contentType, metadata = {}, upsert = false, public: isPublic = false } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file already exists if (bucketFiles.has(filePath) && !upsert) { throw new Error(`File "${filePath}" already exists in bucket "${bucketName}"`); } // Convert data to Buffer if needed let fileData; if (typeof data === 'string') { fileData = Buffer.from(data); } else if (data instanceof Buffer) { fileData = data; } else if (data instanceof Blob) { fileData = Buffer.from(await data.arrayBuffer()); } else { fileData = Buffer.from(data); } // Create file object const file = { id: uuidv4(), name: filePath.split('/').pop(), path: filePath, size: fileData.length, contentType, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), public: isPublic }; // Store file bucketFiles.set(filePath, { ...file, data: fileData }); // Store metadata const metadataKey = `${bucketName}:${filePath}`; this.metadata.set(metadataKey, { ...metadata, contentType, size: fileData.length, createdAt: file.createdAt, updatedAt: file.updatedAt }); return { ...file, metadata: { ...metadata } }; } /** * Download a file * @param {Object} options - Download options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @param {string} options.responseType - Response type ('arraybuffer', 'blob', 'text', 'json', 'stream') * @returns {Promise<Object>} - Download result with data and metadata */ async downloadFile(options) { const { bucketName, path: filePath, responseType = 'arraybuffer' } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists if (!bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Get file const file = bucketFiles.get(filePath); // Get metadata const metadataKey = `${bucketName}:${filePath}`; const metadata = this.metadata.get(metadataKey) || {}; // Convert data based on response type let responseData; switch (responseType) { case 'arraybuffer': responseData = file.data.buffer.slice( file.data.byteOffset, file.data.byteOffset + file.data.byteLength ); break; case 'blob': responseData = new Blob([file.data], { type: file.contentType }); break; case 'text': responseData = file.data.toString('utf-8'); break; case 'json': try { responseData = JSON.parse(file.data.toString('utf-8')); } catch (error) { throw new Error('File is not valid JSON'); } break; case 'stream': // In memory adapter, we don't have real streams, so we create a simple readable stream const { Readable } = await import('stream'); responseData = new Readable(); responseData.push(file.data); responseData.push(null); break; default: responseData = file.data; } return { data: responseData, contentType: file.contentType, size: file.size, metadata: { ...metadata } }; } /** * Get file information * @param {Object} options - File info options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @returns {Promise<Object>} - File information */ async getFileInfo(options) { const { bucketName, path: filePath } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists if (!bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Get file const file = bucketFiles.get(filePath); // Get metadata const metadataKey = `${bucketName}:${filePath}`; const metadata = this.metadata.get(metadataKey) || {}; // Return file info without data const { data, ...fileInfo } = file; return { ...fileInfo, metadata: { ...metadata } }; } /** * List files in a bucket * @param {Object} options - List options * @param {string} options.bucketName - Bucket name * @param {string} options.prefix - Path prefix to filter by * @param {number} options.limit - Maximum number of files to return * @param {string} options.cursor - Pagination cursor * @param {string} options.sortBy - Sort field * @param {boolean} options.sortDescending - Whether to sort in descending order * @returns {Promise<Object>} - List result with files and pagination info */ async listFiles(options) { const { bucketName, prefix = '', limit = 100, cursor, sortBy = 'name', sortDescending = false } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Filter files by prefix let filteredFiles = Array.from(bucketFiles.entries()) .filter(([path]) => path.startsWith(prefix)) .map(([path, file]) => { const { data, ...fileInfo } = file; const metadataKey = `${bucketName}:${path}`; const metadata = this.metadata.get(metadataKey) || {}; return { ...fileInfo, metadata: { ...metadata } }; }); // Sort files filteredFiles.sort((a, b) => { const aValue = a[sortBy]; const bValue = b[sortBy]; if (aValue < bValue) return sortDescending ? 1 : -1; if (aValue > bValue) return sortDescending ? -1 : 1; return 0; }); // Apply pagination let startIndex = 0; if (cursor) { // Find the index of the cursor const cursorIndex = filteredFiles.findIndex(file => file.id === cursor); if (cursorIndex !== -1) { startIndex = cursorIndex + 1; } } // Get paginated files const paginatedFiles = filteredFiles.slice(startIndex, startIndex + limit); // Determine next cursor const nextCursor = paginatedFiles.length === limit ? paginatedFiles[paginatedFiles.length - 1].id : null; return { files: paginatedFiles, cursor: nextCursor, hasMore: nextCursor !== null }; } /** * Delete a file * @param {Object} options - Delete options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @returns {Promise<boolean>} - Whether the file was deleted */ async deleteFile(options) { const { bucketName, path: filePath } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists if (!bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Delete file bucketFiles.delete(filePath); // Delete metadata const metadataKey = `${bucketName}:${filePath}`; this.metadata.delete(metadataKey); return true; } /** * Copy a file * @param {Object} options - Copy options * @param {string} options.sourceBucket - Source bucket name * @param {string} options.sourcePath - Source file path * @param {string} options.destinationBucket - Destination bucket name * @param {string} options.destinationPath - Destination file path * @param {boolean} options.overwrite - Whether to overwrite existing file * @returns {Promise<Object>} - Copy result */ async copyFile(options) { const { sourceBucket, sourcePath, destinationBucket, destinationPath, overwrite = false } = options; // Check if source bucket exists if (!this.buckets.has(sourceBucket)) { throw new Error(`Source bucket "${sourceBucket}" does not exist`); } // Check if destination bucket exists if (!this.buckets.has(destinationBucket)) { throw new Error(`Destination bucket "${destinationBucket}" does not exist`); } // Get source bucket files const sourceBucketFiles = this.files.get(sourceBucket); // Check if source file exists if (!sourceBucketFiles.has(sourcePath)) { throw new Error(`Source file "${sourcePath}" does not exist in bucket "${sourceBucket}"`); } // Get destination bucket files const destinationBucketFiles = this.files.get(destinationBucket); // Check if destination file already exists if (destinationBucketFiles.has(destinationPath) && !overwrite) { throw new Error(`Destination file "${destinationPath}" already exists in bucket "${destinationBucket}"`); } // Get source file const sourceFile = sourceBucketFiles.get(sourcePath); // Get source metadata const sourceMetadataKey = `${sourceBucket}:${sourcePath}`; const sourceMetadata = this.metadata.get(sourceMetadataKey) || {}; // Create destination file const destinationFile = { ...sourceFile, id: uuidv4(), name: destinationPath.split('/').pop(), path: destinationPath, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; // Store destination file destinationBucketFiles.set(destinationPath, { ...destinationFile, data: Buffer.from(sourceFile.data) // Create a copy of the data }); // Store destination metadata const destinationMetadataKey = `${destinationBucket}:${destinationPath}`; this.metadata.set(destinationMetadataKey, { ...sourceMetadata, createdAt: destinationFile.createdAt, updatedAt: destinationFile.updatedAt }); // Return destination file info const { data, ...fileInfo } = destinationFile; return { ...fileInfo, metadata: { ...sourceMetadata } }; } /** * Move a file * @param {Object} options - Move options * @param {string} options.sourceBucket - Source bucket name * @param {string} options.sourcePath - Source file path * @param {string} options.destinationBucket - Destination bucket name * @param {string} options.destinationPath - Destination file path * @param {boolean} options.overwrite - Whether to overwrite existing file * @returns {Promise<Object>} - Move result */ async moveFile(options) { const { sourceBucket, sourcePath, destinationBucket, destinationPath, overwrite = false } = options; // Copy file const copyResult = await this.copyFile({ sourceBucket, sourcePath, destinationBucket, destinationPath, overwrite }); // Delete source file await this.deleteFile({ bucketName: sourceBucket, path: sourcePath }); return copyResult; } /** * Get a public URL for a file * @param {Object} options - URL options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @returns {Promise<string>} - Public URL */ async getFileUrl(options) { const { bucketName, path: filePath } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists if (!bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Get file const file = bucketFiles.get(filePath); // Check if file is public if (!file.public) { throw new Error(`File "${filePath}" is not public`); } // In memory adapter, we don't have real URLs, so we return a fake URL return `memory://${bucketName}/${filePath}`; } /** * Get a signed URL for a file * @param {Object} options - Signed URL options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @param {number} options.expiresIn - Expiration time in seconds * @param {string} options.action - Action ('read', 'write', 'delete') * @returns {Promise<string>} - Signed URL */ async getSignedUrl(options) { const { bucketName, path: filePath, expiresIn = 3600, action = 'read' } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists (except for 'write' action) if (action !== 'write' && !bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Generate expiration timestamp const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; // In memory adapter, we don't have real signed URLs, so we return a fake URL return `memory://${bucketName}/${filePath}?action=${action}&expires=${expiresAt}`; } /** * Update file metadata * @param {Object} options - Metadata options * @param {string} options.bucketName - Bucket name * @param {string} options.path - File path within the bucket * @param {Object} options.metadata - Metadata to update * @returns {Promise<Object>} - Updated metadata */ async updateMetadata(options) { const { bucketName, path: filePath, metadata = {} } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Check if file exists if (!bucketFiles.has(filePath)) { throw new Error(`File "${filePath}" does not exist in bucket "${bucketName}"`); } // Get file const file = bucketFiles.get(filePath); // Update file's updatedAt file.updatedAt = new Date().toISOString(); // Get metadata key const metadataKey = `${bucketName}:${filePath}`; // Get existing metadata const existingMetadata = this.metadata.get(metadataKey) || {}; // Update metadata const updatedMetadata = { ...existingMetadata, ...metadata, updatedAt: file.updatedAt }; // Store updated metadata this.metadata.set(metadataKey, updatedMetadata); return { ...updatedMetadata }; } /** * Search for files * @param {Object} options - Search options * @param {string} options.bucketName - Bucket name * @param {string} options.prefix - Path prefix to filter by * @param {Object} options.metadata - Metadata to filter by * @param {number} options.limit - Maximum number of files to return * @param {string} options.cursor - Pagination cursor * @returns {Promise<Object>} - Search result with files and pagination info */ async searchFiles(options) { const { bucketName, prefix = '', metadata = {}, limit = 100, cursor } = options; // Check if bucket exists if (!this.buckets.has(bucketName)) { throw new Error(`Bucket "${bucketName}" does not exist`); } // Get bucket files const bucketFiles = this.files.get(bucketName); // Filter files by prefix and metadata let filteredFiles = Array.from(bucketFiles.entries()) .filter(([path]) => path.startsWith(prefix)) .map(([path, file]) => { const { data, ...fileInfo } = file; const metadataKey = `${bucketName}:${path}`; const fileMetadata = this.metadata.get(metadataKey) || {}; return { ...fileInfo, metadata: { ...fileMetadata } }; }) .filter(file => { // Check if file metadata matches all search metadata for (const [key, value] of Object.entries(metadata)) { if (file.metadata[key] !== value) { return false; } } return true; }); // Apply pagination let startIndex = 0; if (cursor) { // Find the index of the cursor const cursorIndex = filteredFiles.findIndex(file => file.id === cursor); if (cursorIndex !== -1) { startIndex = cursorIndex + 1; } } // Get paginated files const paginatedFiles = filteredFiles.slice(startIndex, startIndex + limit); // Determine next cursor const nextCursor = paginatedFiles.length === limit ? paginatedFiles[paginatedFiles.length - 1].id : null; return { files: paginatedFiles, cursor: nextCursor, hasMore: nextCursor !== null }; } /** * Clear all data (for testing) */ clear() { this.buckets.clear(); this.files.clear(); this.metadata.clear(); } } export default MemoryAdapter;