@profullstack/storage-service
Version:
Flexible storage service abstraction for file storage, retrieval, and metadata management
650 lines (576 loc) • 16.8 kB
JavaScript
/**
* Supabase adapter for @profullstack/storage-service
*/
/**
* Supabase Storage Adapter
*/
export class SupabaseAdapter {
/**
* Create a new Supabase adapter
* @param {Object} options - Supabase options
* @param {Object} options.supabaseClient - Initialized Supabase client
* @param {string} options.defaultBucket - Default bucket name (default: 'default')
*/
constructor(options = {}) {
if (!options.supabaseClient) {
throw new Error('Supabase client is required');
}
this.supabase = options.supabaseClient;
this.defaultBucket = options.defaultBucket || 'default';
}
/**
* Create a bucket
* @param {string} bucketName - Bucket name
* @param {Object} options - Bucket options
* @returns {Promise<Object>} - Bucket information
*/
async createBucket(bucketName, options = {}) {
try {
// Create bucket in Supabase Storage
const { data, error } = await this.supabase.storage.createBucket(bucketName, {
public: options.public || false,
fileSizeLimit: options.fileSizeLimit,
allowedMimeTypes: options.allowedMimeTypes
});
if (error) {
throw error;
}
return {
name: bucketName,
...options,
createdAt: new Date().toISOString()
};
} catch (error) {
throw error;
}
}
/**
* List all buckets
* @returns {Promise<Array>} - List of buckets
*/
async listBuckets() {
try {
const { data, error } = await this.supabase.storage.listBuckets();
if (error) {
throw error;
}
return data || [];
} catch (error) {
throw error;
}
}
/**
* Delete a bucket
* @param {string} bucketName - Bucket name
* @param {Object} options - Delete options
* @returns {Promise<boolean>} - Whether the bucket was deleted
*/
async deleteBucket(bucketName, options = {}) {
try {
// Check if bucket is empty if not force deleting
if (!options.force) {
const { data: files, error: listError } = await this.supabase.storage
.from(bucketName)
.list();
if (listError) {
throw listError;
}
if (files && files.length > 0) {
throw new Error('Bucket is not empty. Use force option to delete anyway.');
}
}
// Delete bucket
const { error } = await this.supabase.storage.deleteBucket(bucketName);
if (error) {
throw error;
}
return true;
} catch (error) {
throw error;
}
}
/**
* Upload a file
* @param {Object} options - Upload options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @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) {
try {
const {
bucketName,
path: filePath,
data,
contentType,
metadata,
upsert = false,
public: isPublic
} = options;
// Upload file
const { data: uploadData, error } = await this.supabase.storage
.from(bucketName)
.upload(filePath, data, {
contentType,
upsert,
cacheControl: '3600',
...(metadata && { metadata })
});
if (error) {
throw error;
}
// Set public access if needed
if (isPublic) {
await this._setPublicAccess(bucketName, filePath, true);
}
// Get file URL
const { data: publicUrl } = this.supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
return {
path: filePath,
url: publicUrl?.publicUrl || null,
metadata,
contentType,
size: data.length || (data.size || 0),
isPublic,
uploadedAt: new Date().toISOString()
};
} catch (error) {
throw error;
}
}
/**
* Download a file
* @param {Object} options - Download options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @param {string} options.responseType - Response type
* @returns {Promise<Object>} - Download result with data and metadata
*/
async downloadFile(options) {
try {
const {
bucketName,
path: filePath,
responseType = 'arraybuffer'
} = options;
// Download file
const { data, error } = await this.supabase.storage
.from(bucketName)
.download(filePath);
if (error) {
throw error;
}
// Get file metadata
const fileInfo = await this.getFileInfo({
bucketName,
path: filePath
});
// Convert to requested response type
let responseData = data;
if (responseType === 'text' && data instanceof Blob) {
responseData = await data.text();
} else if (responseType === 'json' && data instanceof Blob) {
const text = await data.text();
responseData = JSON.parse(text);
}
return {
data: responseData,
metadata: fileInfo.metadata,
contentType: fileInfo.contentType,
size: fileInfo.size
};
} catch (error) {
throw error;
}
}
/**
* Get file information
* @param {Object} options - File info options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @returns {Promise<Object>} - File information
*/
async getFileInfo(options) {
try {
const {
bucketName,
path: filePath
} = options;
// Get file metadata
const { data, error } = await this.supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
if (error) {
throw error;
}
// Get file metadata
const { data: metadata, error: metadataError } = await this.supabase.storage
.from(bucketName)
.getMetadata(filePath);
if (metadataError) {
throw metadataError;
}
return {
path: filePath,
url: data?.publicUrl || null,
metadata: metadata?.metadata || {},
contentType: metadata?.contentType || 'application/octet-stream',
size: metadata?.size || 0,
createdAt: metadata?.created_at || new Date().toISOString(),
lastModified: metadata?.last_modified || new Date().toISOString()
};
} catch (error) {
throw error;
}
}
/**
* List files in a bucket
* @param {Object} options - List options
* @param {string} options.bucketName - Bucket name
* @param {string} options.prefix - Path prefix
* @param {number} options.limit - Maximum number of files
* @param {string} options.cursor - Pagination cursor
* @param {string} options.sortBy - Sort field
* @param {boolean} options.sortDescending - Sort direction
* @returns {Promise<Object>} - List result
*/
async listFiles(options) {
try {
const {
bucketName,
prefix = '',
limit = 100,
cursor,
sortBy = 'name',
sortDescending = false
} = options;
// List files
const { data, error } = await this.supabase.storage
.from(bucketName)
.list(prefix, {
limit,
offset: cursor ? parseInt(cursor, 10) : 0,
sortBy: {
column: sortBy,
order: sortDescending ? 'desc' : 'asc'
}
});
if (error) {
throw error;
}
// Format files
const files = await Promise.all(
(data || []).map(async (file) => {
const filePath = prefix ? `${prefix}/${file.name}` : file.name;
// Get file URL
const { data: urlData } = this.supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
return {
path: filePath,
url: urlData?.publicUrl || null,
metadata: file.metadata || {},
contentType: file.metadata?.contentType || 'application/octet-stream',
size: file.metadata?.size || 0,
createdAt: file.created_at || new Date().toISOString(),
lastModified: file.last_modified || new Date().toISOString()
};
})
);
// Calculate next cursor
const nextCursor = data && data.length === limit
? (cursor ? parseInt(cursor, 10) : 0) + limit
: null;
return {
files,
cursor: nextCursor ? nextCursor.toString() : null
};
} catch (error) {
throw error;
}
}
/**
* Delete a file
* @param {Object} options - Delete options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @returns {Promise<boolean>} - Whether the file was deleted
*/
async deleteFile(options) {
try {
const {
bucketName,
path: filePath
} = options;
// Delete file
const { error } = await this.supabase.storage
.from(bucketName)
.remove([filePath]);
if (error) {
throw error;
}
return true;
} catch (error) {
throw error;
}
}
/**
* Copy a file
* @param {Object} options - Copy options
* @param {string} options.sourceBucket - Source bucket
* @param {string} options.sourcePath - Source path
* @param {string} options.destinationBucket - Destination bucket
* @param {string} options.destinationPath - Destination path
* @param {boolean} options.overwrite - Whether to overwrite
* @returns {Promise<Object>} - Copy result
*/
async copyFile(options) {
try {
const {
sourceBucket,
sourcePath,
destinationBucket,
destinationPath,
overwrite = false
} = options;
// Check if destination exists and we're not overwriting
if (!overwrite) {
try {
const destInfo = await this.getFileInfo({
bucketName: destinationBucket,
path: destinationPath
});
if (destInfo) {
throw new Error('Destination file already exists');
}
} catch (error) {
// If error is because file doesn't exist, that's fine
if (!error.message.includes('not found')) {
throw error;
}
}
}
// Download source file
const sourceFile = await this.downloadFile({
bucketName: sourceBucket,
path: sourcePath
});
// Upload to destination
return await this.uploadFile({
bucketName: destinationBucket,
path: destinationPath,
data: sourceFile.data,
contentType: sourceFile.contentType,
metadata: sourceFile.metadata,
upsert: overwrite
});
} catch (error) {
throw error;
}
}
/**
* Move a file
* @param {Object} options - Move options
* @param {string} options.sourceBucket - Source bucket
* @param {string} options.sourcePath - Source path
* @param {string} options.destinationBucket - Destination bucket
* @param {string} options.destinationPath - Destination path
* @param {boolean} options.overwrite - Whether to overwrite
* @returns {Promise<Object>} - Move result
*/
async moveFile(options) {
try {
const {
sourceBucket,
sourcePath,
destinationBucket,
destinationPath,
overwrite = false
} = options;
// Copy the file
const copyResult = await this.copyFile({
sourceBucket,
sourcePath,
destinationBucket,
destinationPath,
overwrite
});
// Delete the source file
await this.deleteFile({
bucketName: sourceBucket,
path: sourcePath
});
return copyResult;
} catch (error) {
throw error;
}
}
/**
* Get a public URL for a file
* @param {Object} options - URL options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @returns {Promise<string>} - Public URL
*/
async getFileUrl(options) {
try {
const {
bucketName,
path: filePath
} = options;
// Get public URL
const { data, error } = this.supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
if (error) {
throw error;
}
return data?.publicUrl || null;
} catch (error) {
throw error;
}
}
/**
* 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
* @param {number} options.expiresIn - Expiration time in seconds
* @param {string} options.action - Action (read, write, delete)
* @returns {Promise<string>} - Signed URL
*/
async getSignedUrl(options) {
try {
const {
bucketName,
path: filePath,
expiresIn = 3600,
action = 'read'
} = options;
// Map action to Supabase action
const supabaseAction = action === 'write' ? 'upload' : action;
// Get signed URL
const { data, error } = await this.supabase.storage
.from(bucketName)
.createSignedUrl(filePath, expiresIn, {
download: action === 'read'
});
if (error) {
throw error;
}
return data?.signedUrl || null;
} catch (error) {
throw error;
}
}
/**
* Update file metadata
* @param {Object} options - Metadata options
* @param {string} options.bucketName - Bucket name
* @param {string} options.path - File path
* @param {Object} options.metadata - Metadata to update
* @returns {Promise<Object>} - Updated metadata
*/
async updateMetadata(options) {
try {
const {
bucketName,
path: filePath,
metadata
} = options;
// Supabase doesn't have a direct method to update metadata
// We need to download and re-upload the file with new metadata
// Download file
const file = await this.downloadFile({
bucketName,
path: filePath
});
// Upload with new metadata
await this.uploadFile({
bucketName,
path: filePath,
data: file.data,
contentType: file.contentType,
metadata,
upsert: true
});
return metadata;
} catch (error) {
throw error;
}
}
/**
* Search for files
* @param {Object} options - Search options
* @param {string} options.bucketName - Bucket name
* @param {string} options.prefix - Path prefix
* @param {Object} options.metadata - Metadata to filter by
* @param {number} options.limit - Maximum number of files
* @param {string} options.cursor - Pagination cursor
* @returns {Promise<Object>} - Search result
*/
async searchFiles(options) {
try {
const {
bucketName,
prefix = '',
metadata = {},
limit = 100,
cursor
} = options;
// List files
const { files, cursor: nextCursor } = await this.listFiles({
bucketName,
prefix,
limit,
cursor
});
// Filter by metadata
const filteredFiles = files.filter(file => {
// Check if file metadata matches all criteria
return Object.entries(metadata).every(([key, value]) => {
return file.metadata && file.metadata[key] === value;
});
});
return {
files: filteredFiles,
cursor: nextCursor
};
} catch (error) {
throw error;
}
}
/**
* Set public access for a file
* @private
* @param {string} bucketName - Bucket name
* @param {string} filePath - File path
* @param {boolean} isPublic - Whether the file should be public
* @returns {Promise<void>}
*/
async _setPublicAccess(bucketName, filePath, isPublic) {
try {
// Supabase doesn't have a direct method to set public access for individual files
// Public access is controlled at the bucket level
// This is a placeholder for future implementation
} catch (error) {
throw error;
}
}
}
/**
* Create a Supabase adapter
* @param {Object} options - Supabase options
* @returns {SupabaseAdapter} Supabase adapter
*/
export function createSupabaseAdapter(options = {}) {
return new SupabaseAdapter(options);
}
export default createSupabaseAdapter;