# @memberjunction/storage The `@memberjunction/storage` library provides a unified interface for interacting with various cloud storage providers. It abstracts the complexities of different storage services behind a consistent API, making it easy to work with files stored across different cloud platforms. Part of the [MemberJunction](https://github.com/MemberJunction/MJ) framework. ## Overview This library is a key component of the MemberJunction platform, providing seamless file storage operations across multiple cloud providers. It offers a provider-agnostic approach to file management, allowing applications to switch between storage providers without code changes. The package supports both simple single-tenant deployments using environment variables and enterprise multi-tenant deployments using the MemberJunction Credential Engine for secure credential management. ## Architecture The library is organized around an abstract base class (`FileStorageBase`) with concrete driver implementations for each supported cloud storage provider. A set of high-level utility functions in `util.ts` bridge the gap between MemberJunction entities and the underlying drivers. Configuration is handled through Zod-validated schemas loaded from `mj.config.cjs` or environment variables. ```mermaid graph TB subgraph Application["Application Layer"] style Application fill:#2d6a9f,stroke:#1a4971,color:#fff UtilFunctions["Utility Functions
createUploadUrl, createDownloadUrl,
moveObject, deleteObject, listObjects,
copyObjectBetweenProviders,
searchAcrossProviders"] end subgraph Core["Core Abstractions"] style Core fill:#7c5295,stroke:#563a6b,color:#fff FSBase["FileStorageBase
(Abstract Base Class)"] Config["StorageConfig
(Zod Schema)"] end subgraph Drivers["Storage Provider Drivers"] style Drivers fill:#2d8659,stroke:#1a5c3a,color:#fff AWS["AWSFileStorage
'AWS S3'"] Azure["AzureFileStorage
'Azure Blob Storage'"] GCS["GoogleFileStorage
'Google Cloud Storage'"] GDrive["GoogleDriveFileStorage
'Google Drive Storage'"] SP["SharePointFileStorage
'SharePoint'"] Dropbox["DropboxFileStorage
'Dropbox'"] Box["BoxFileStorage
'Box'"] end subgraph MJ["MemberJunction Integration"] style MJ fill:#b8762f,stroke:#8a5722,color:#fff ClassFactory["MJGlobal ClassFactory
@RegisterClass"] Entities["FileStorageProviderEntity
FileStorageAccountEntity"] CredEngine["CredentialEngine
Secure credential decryption"] end UtilFunctions --> FSBase UtilFunctions --> ClassFactory UtilFunctions --> Entities UtilFunctions --> CredEngine FSBase --> Config AWS --> FSBase Azure --> FSBase GCS --> FSBase GDrive --> FSBase SP --> FSBase Dropbox --> FSBase Box --> FSBase ClassFactory --> AWS ClassFactory --> Azure ClassFactory --> GCS ClassFactory --> GDrive ClassFactory --> SP ClassFactory --> Dropbox ClassFactory --> Box ``` ## Features - **Unified API**: Consistent methods across all storage providers via the `FileStorageBase` abstract class - **Type-Safe**: Full TypeScript support with comprehensive type definitions - **Flexible Provider Selection**: Use any number of storage providers simultaneously based on your application needs - **Pre-authenticated URLs**: Secure upload and download operations using time-limited URLs - **HTTP Range Streaming**: Serve large audio/video via `GetObjectStream` without buffering whole files in memory (all seven providers: AWS S3, Azure Blob, Google Cloud Storage, Google Drive, SharePoint, Dropbox, Box). Capability is introspectable via `SupportsStreaming`. See [docs/STREAMING.md](docs/STREAMING.md). - **Metadata Support**: Store and retrieve custom metadata with your files - **Error Handling**: Provider-specific errors are normalized with clear error messages - **Zod-Validated Configuration**: All configuration schemas are validated at load time via Zod - **Enterprise Credential Management**: Integrates with `@memberjunction/credentials` for secure, multi-tenant credential storage and decryption - **Cross-Provider Copy**: Copy files between different storage providers server-side - **Multi-Provider Search**: Search for files across multiple providers or accounts in parallel - **Token Refresh Persistence**: Automatic callback system to persist new OAuth tokens (critical for providers like Box that rotate refresh tokens) - **Extensible**: Easy to add new storage providers by extending `FileStorageBase` ### Supported Storage Providers | Provider | Driver Key | Search | Pre-auth URLs | Native Directories | Range Streaming | |---|---|---|---|---|---| | [AWS S3](https://aws.amazon.com/s3/) | `AWS S3` | No | Yes | Simulated | Yes | | [Azure Blob Storage](https://azure.microsoft.com/en-us/products/storage/blobs) | `Azure Blob Storage` | No | Yes | Simulated | Yes | | [Google Cloud Storage](https://cloud.google.com/storage) | `Google Cloud Storage` | No | Yes | Simulated | Yes | | [Google Drive](https://developers.google.com/drive/api/guides/about-sdk) | `Google Drive Storage` | Yes (content) | Yes | Native | Yes (non-Workspace files) | | [Microsoft SharePoint](https://learn.microsoft.com/en-us/sharepoint/dev/) | `SharePoint` | Yes (content) | Yes | Native | Yes | | [Dropbox](https://www.dropbox.com/developers/documentation) | `Dropbox` | Yes (content) | Yes | Native | Yes | | [Box](https://developer.box.com/guides/) | `Box` | Yes (metadata) | Yes | Native | Yes | ### File Operations - Upload files (via pre-authenticated URLs or direct Buffer upload) - Download files (via pre-authenticated URLs or direct Buffer download) - Copy and move files (within and across providers) - Delete files and directories (with optional recursive deletion) - List files and directories with full metadata - Create and manage directories - Get detailed file metadata without downloading content - Check file/directory existence - Search files using native provider search APIs ## Installation ```bash npm install @memberjunction/storage ``` ## Dependencies This package depends on: | Package | Purpose | |---|---| | `@memberjunction/core` | Core MemberJunction functionality (logging, UserInfo) | | `@memberjunction/core-entities` | Entity definitions (FileStorageProviderEntity, FileStorageAccountEntity) | | `@memberjunction/global` | Global class factory and `@RegisterClass` decorator | | `@memberjunction/credentials` | Credential Engine for secure credential decryption | | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` | AWS S3 SDK | | `@azure/storage-blob`, `@azure/identity` | Azure Blob Storage SDK | | `@google-cloud/storage` | Google Cloud Storage SDK | | `googleapis` | Google Drive API | | `@microsoft/microsoft-graph-client` | SharePoint via Microsoft Graph | | `dropbox` | Dropbox SDK | | `box-node-sdk` | Box SDK | | `cosmiconfig` | Configuration file loading | | `zod` | Configuration schema validation | | `env-var` | Environment variable parsing | | `mime-types` | MIME type detection | ## Usage ### Initialization Flow Every storage driver follows a two-step initialization pattern. The `initialize()` method is smart enough to handle both simple deployments (environment variables) and multi-tenant deployments (database credentials). ```mermaid flowchart TD Start(["Create Driver Instance"]) --> Constructor["Constructor
Loads env vars / config defaults"] Constructor --> Init{"Call initialize()"} Init -->|"No config"| EnvPath["Use environment variables
already loaded by constructor"] Init -->|"With config"| ConfigPath["Override credentials
with provided config"] EnvPath --> SetAccount["Set accountId / accountName
if provided"] ConfigPath --> SetAccount SetAccount --> Reinit["Reinitialize SDK client
with final credentials"] Reinit --> Ready(["Driver Ready"]) style Start fill:#2d6a9f,stroke:#1a4971,color:#fff style Ready fill:#2d8659,stroke:#1a5c3a,color:#fff style Init fill:#b8762f,stroke:#8a5722,color:#fff style Constructor fill:#7c5295,stroke:#563a6b,color:#fff style EnvPath fill:#7c5295,stroke:#563a6b,color:#fff style ConfigPath fill:#7c5295,stroke:#563a6b,color:#fff style SetAccount fill:#7c5295,stroke:#563a6b,color:#fff style Reinit fill:#7c5295,stroke:#563a6b,color:#fff ``` ### Simple Deployment (Environment Variables) For single-tenant applications, development, testing, or simple production deployments: ```bash # Example: Azure Blob Storage export STORAGE_AZURE_CONTAINER=your-container-name export STORAGE_AZURE_ACCOUNT_NAME=your-account-name export STORAGE_AZURE_ACCOUNT_KEY=your-account-key ``` ```typescript import { AzureFileStorage } from '@memberjunction/storage'; // Constructor loads environment variables const storage = new AzureFileStorage(); // ALWAYS call initialize() - no config needed for env var deployments await storage.initialize(); // Provider is now ready to use await storage.ListObjects('/'); ``` ### Multi-Tenant Enterprise (Database Credentials) For enterprise applications managing multiple storage accounts via the MemberJunction entity system and Credential Engine: ```typescript import { FileStorageEngine } from '@memberjunction/core-entities'; import { initializeDriverWithAccountCredentials } from '@memberjunction/storage'; // Load account from database const engine = FileStorageEngine.Instance; await engine.Config(false, contextUser); const accountWithProvider = engine.GetAccountWithProvider(accountId); // Infrastructure utility handles credential decryption and initialization const storage = await initializeDriverWithAccountCredentials({ accountEntity: accountWithProvider.account, providerEntity: accountWithProvider.provider, contextUser }); // Provider is ready - credentials were automatically decrypted and initialized await storage.ListObjects('/'); ``` The `initializeDriverWithAccountCredentials()` utility: - Creates the driver instance via the MJGlobal ClassFactory - Retrieves the credential by ID from the Credential Engine - Decrypts the credential values - Configures a token refresh callback to persist rotated tokens back to the database - Calls `initialize()` with decrypted values and account information ### Enterprise Credential Flow ```mermaid sequenceDiagram participant App as Application participant Util as initializeDriverWithAccountCredentials participant CF as ClassFactory participant CE as CredentialEngine participant Driver as Storage Driver App->>Util: { accountEntity, providerEntity, contextUser } Util->>CF: CreateInstance(FileStorageBase, driverKey) CF-->>Util: driver instance alt Account has CredentialID Util->>CE: Config(false, contextUser) Util->>CE: getCredentialById(credentialID) CE-->>Util: credentialEntity Util->>CE: getCredential(name, options) CE-->>Util: { values: decrypted credentials } Util->>Util: Create onTokenRefresh callback Util->>Driver: initialize({ accountId, ...decryptedValues, onTokenRefresh }) else No CredentialID alt Provider has Configuration JSON Util->>Driver: initialize({ accountId, ...providerConfig }) else No configuration Util->>Driver: initialize({ accountId }) end end Driver-->>App: initialized driver ``` ### Using Utility Functions The library provides high-level utility functions that work with MemberJunction's entity system: ```typescript import { createUploadUrl, createDownloadUrl, moveObject, deleteObject, listObjects, copyObject } from '@memberjunction/storage'; import { FileStorageProviderEntity } from '@memberjunction/core-entities'; import { Metadata } from '@memberjunction/core'; async function fileOperationsExample() { const md = new Metadata(); const provider = await md.GetEntityObject('File Storage Providers'); await provider.Load('your-provider-id'); // Create pre-authenticated upload URL const { updatedInput, UploadUrl } = await createUploadUrl( provider, { ID: '123', Name: 'documents/report.pdf', ProviderID: provider.ID } ); // The client can use the UploadUrl directly to upload the file console.log(`Upload URL: ${UploadUrl}`); console.log(`File status: ${updatedInput.Status}`); // 'Uploading' console.log(`Content type: ${updatedInput.ContentType}`); // 'application/pdf' // If a ProviderKey was returned, use it for future operations const fileIdentifier = updatedInput.ProviderKey || updatedInput.Name; // Create pre-authenticated download URL const downloadUrl = await createDownloadUrl(provider, fileIdentifier); // List directory contents const contents = await listObjects(provider, 'documents/'); for (const file of contents.objects) { console.log(`${file.name} (${file.size} bytes)`); } // Copy a file await copyObject(provider, fileIdentifier, 'documents/report-backup.pdf'); // Move a file await moveObject(provider, fileIdentifier, 'archive/report.pdf'); // Delete a file await deleteObject(provider, 'archive/report.pdf'); } ``` ### Direct Provider Usage You can work directly with a storage provider by instantiating it: ```typescript import { AzureFileStorage, FileStorageBase } from '@memberjunction/storage'; import { MJGlobal } from '@memberjunction/global'; async function directProviderExample() { // Method 1: Direct instantiation (simple deployment with env vars) const storage = new AzureFileStorage(); await storage.initialize(); // Method 2: Using class factory (dynamic provider selection) const storage2 = MJGlobal.Instance.ClassFactory.CreateInstance( FileStorageBase, 'Azure Blob Storage' ); await storage2.initialize(); // List all files in a directory const result = await storage.ListObjects('documents/'); console.log('Files:', result.objects); console.log('Directories:', result.prefixes); // Upload a file directly with metadata const content = Buffer.from('Hello, World!'); await storage.PutObject( 'documents/reports/hello.txt', content, 'text/plain', { author: 'John Doe', department: 'Engineering' } ); // Get file metadata without downloading content const metadata = await storage.GetObjectMetadata({ fullPath: 'documents/reports/hello.txt' }); console.log('File metadata:', metadata); // Download file content const fileContent = await storage.GetObject({ fullPath: 'documents/reports/hello.txt' }); console.log('File content:', fileContent.toString('utf8')); // Copy a file await storage.CopyObject( 'documents/reports/hello.txt', 'documents/archive/hello-backup.txt' ); // Check if a file exists const exists = await storage.ObjectExists('documents/reports/hello.txt'); // Check if a directory exists const dirExists = await storage.DirectoryExists('documents/reports/'); // Delete a directory and all its contents await storage.DeleteDirectory('documents/reports/', true); } ``` ### Cross-Provider File Copy Transfer files between different storage providers server-side: ```typescript import { copyObjectBetweenProviders } from '@memberjunction/storage'; const result = await copyObjectBetweenProviders( sourceProviderEntity, destProviderEntity, 'documents/report.pdf', 'imported/report.pdf', { sourceUserContext: { userID: currentUser.ID, contextUser }, destinationUserContext: { userID: currentUser.ID, contextUser } } ); if (result.success) { console.log(`Transferred ${result.bytesTransferred} bytes`); } ``` ```mermaid flowchart LR Source["Source Provider
(e.g. Dropbox)"] -->|"GetObject()"| Server["MJ Server
(in-memory Buffer)"] Server -->|"PutObject()"| Dest["Destination Provider
(e.g. Google Drive)"] style Source fill:#2d6a9f,stroke:#1a4971,color:#fff style Server fill:#b8762f,stroke:#8a5722,color:#fff style Dest fill:#2d8659,stroke:#1a5c3a,color:#fff ``` ### Searching Files Providers with native search capabilities support the `SearchFiles` method: ```typescript import { FileStorageBase, UnsupportedOperationError } from '@memberjunction/storage'; try { // Simple search const results = await storage.SearchFiles('quarterly report'); for (const file of results.results) { console.log(` ${file.path} (${file.size} bytes)`); if (file.excerpt) { console.log(` Excerpt: ${file.excerpt}`); } } // Advanced search with filters const filtered = await storage.SearchFiles('budget 2024', { fileTypes: ['pdf', 'docx'], modifiedAfter: new Date('2024-01-01'), pathPrefix: 'documents/finance/', maxResults: 50, searchContent: true }); if (filtered.hasMore) { console.log(`Total matches: ${filtered.totalMatches}`); } } catch (error) { if (error instanceof UnsupportedOperationError) { console.log('This provider does not support file search'); } } ``` ### Multi-Provider and Multi-Account Search Search across multiple providers or accounts in parallel: ```typescript import { searchAcrossProviders, searchAcrossAccounts } from '@memberjunction/storage'; // Search across multiple providers const providerResults = await searchAcrossProviders( [googleDriveProvider, dropboxProvider, boxProvider], 'quarterly report', { maxResultsPerProvider: 25, fileTypes: ['pdf', 'docx'], providerUserContexts: new Map([ [googleDriveProvider.ID, { userID: currentUser.ID, contextUser }], [dropboxProvider.ID, { userID: currentUser.ID, contextUser }] ]) } ); for (const pr of providerResults.providerResults) { if (pr.success) { console.log(`${pr.providerName}: ${pr.results.length} results`); } else { console.log(`${pr.providerName}: ${pr.errorMessage}`); } } // Enterprise: Search across multiple accounts (including multiple accounts of same type) const accountResults = await searchAcrossAccounts( [ { accountEntity: researchDropbox, providerEntity: dropboxProvider }, { accountEntity: marketingDropbox, providerEntity: dropboxProvider }, { accountEntity: engineeringGDrive, providerEntity: gdriveProvider } ], 'quarterly report', { maxResultsPerAccount: 25, fileTypes: ['pdf', 'docx'], contextUser: currentUser } ); console.log(`Total results: ${accountResults.totalResultsReturned}`); console.log(`Successful: ${accountResults.successfulAccounts}`); console.log(`Failed: ${accountResults.failedAccounts}`); ``` ## Configuration ### Configuration File (`mj.config.cjs`) Storage providers can be configured via the `mj.config.cjs` file at the repository root. The configuration is validated using Zod schemas at load time. ```javascript module.exports = { storageProviders: { aws: { accessKeyID: 'your-key', secretAccessKey: 'your-secret', region: 'us-east-1', defaultBucket: 'my-bucket', keyPrefix: '/' }, azure: { accountName: 'your-account', accountKey: 'your-key', connectionString: 'optional-conn-string', defaultContainer: 'my-container' }, googleCloud: { projectID: 'your-project', keyFilename: '/path/to/keyfile.json', keyJSON: '{"type":"service_account",...}', defaultBucket: 'my-bucket' }, googleDrive: { clientID: 'your-client-id', clientSecret: 'your-client-secret', refreshToken: 'your-refresh-token', rootFolderID: 'optional-root-folder' }, dropbox: { accessToken: 'your-access-token', refreshToken: 'your-refresh-token', clientID: 'your-app-key', clientSecret: 'your-app-secret', rootPath: '/optional/root' }, box: { clientID: 'your-client-id', clientSecret: 'your-client-secret', accessToken: 'your-access-token', refreshToken: 'your-refresh-token', enterpriseID: 'your-enterprise-id', rootFolderID: '0' }, sharePoint: { clientID: 'your-client-id', clientSecret: 'your-client-secret', tenantID: 'your-tenant-id', siteID: 'your-site-id', driveID: 'your-drive-id', rootFolderID: 'optional-root-folder' } } }; ``` ### Environment Variables Each provider also supports environment variable configuration. Environment variables take lower priority than config file values (config file wins if both are present). #### AWS S3 | Variable | Description | |---|---| | `STORAGE_AWS_ACCESS_KEY_ID` | AWS access key ID | | `STORAGE_AWS_SECRET_ACCESS_KEY` | AWS secret access key | | `STORAGE_AWS_REGION` | AWS region (e.g., `us-east-1`) | | `STORAGE_AWS_BUCKET_NAME` | S3 bucket name | | `STORAGE_AWS_KEY_PREFIX` | Key prefix (defaults to `/`) | #### Azure Blob Storage | Variable | Description | |---|---| | `STORAGE_AZURE_ACCOUNT_NAME` | Storage account name | | `STORAGE_AZURE_ACCOUNT_KEY` | Storage account key | | `STORAGE_AZURE_CONNECTION_STRING` | Connection string (alternative to name/key) | | `STORAGE_AZURE_CONTAINER` or `STORAGE_AZURE_DEFAULT_CONTAINER` | Container name | #### Google Cloud Storage | Variable | Description | |---|---| | `STORAGE_GOOGLE_KEY_JSON` | JSON string of service account credentials | | `STORAGE_GOOGLE_CLOUD_KEY_FILENAME` | Path to service account key file | | `STORAGE_GOOGLE_BUCKET_NAME` or `STORAGE_GOOGLE_CLOUD_DEFAULT_BUCKET` | GCS bucket name | | `STORAGE_GOOGLE_CLOUD_PROJECT_ID` | Google Cloud project ID | #### Google Drive | Variable | Description | |---|---| | `STORAGE_GOOGLE_DRIVE_CLIENT_ID` | OAuth client ID | | `STORAGE_GOOGLE_DRIVE_CLIENT_SECRET` | OAuth client secret | | `STORAGE_GOOGLE_DRIVE_REFRESH_TOKEN` | OAuth refresh token | | `STORAGE_GOOGLE_DRIVE_REDIRECT_URI` | OAuth redirect URI | | `STORAGE_GDRIVE_ROOT_FOLDER_ID` | Root folder ID (optional) | | `STORAGE_GDRIVE_KEY_FILE` | Service account key file (legacy) | | `STORAGE_GDRIVE_CREDENTIALS_JSON` | Service account credentials JSON (legacy) | #### SharePoint | Variable | Description | |---|---| | `STORAGE_SHAREPOINT_CLIENT_ID` | Azure AD client ID | | `STORAGE_SHAREPOINT_CLIENT_SECRET` | Azure AD client secret | | `STORAGE_SHAREPOINT_TENANT_ID` | Azure AD tenant ID | | `STORAGE_SHAREPOINT_SITE_ID` | SharePoint site ID | | `STORAGE_SHAREPOINT_DRIVE_ID` | Document library drive ID | | `STORAGE_SHAREPOINT_ROOT_FOLDER_ID` | Root folder ID (optional) | #### Dropbox | Variable | Description | |---|---| | `STORAGE_DROPBOX_ACCESS_TOKEN` | Dropbox access token | | `STORAGE_DROPBOX_REFRESH_TOKEN` | Dropbox refresh token | | `STORAGE_DROPBOX_CLIENT_ID` or `STORAGE_DROPBOX_APP_KEY` | App key | | `STORAGE_DROPBOX_CLIENT_SECRET` or `STORAGE_DROPBOX_APP_SECRET` | App secret | | `STORAGE_DROPBOX_ROOT_PATH` | Root path (optional) | #### Box | Variable | Description | |---|---| | `STORAGE_BOX_CLIENT_ID` | Box client ID | | `STORAGE_BOX_CLIENT_SECRET` | Box client secret | | `STORAGE_BOX_ACCESS_TOKEN` | Box access token | | `STORAGE_BOX_REFRESH_TOKEN` | Box refresh token | | `STORAGE_BOX_ENTERPRISE_ID` | Box enterprise ID (for JWT auth) | | `STORAGE_BOX_ROOT_FOLDER_ID` | Root folder ID (optional) | ## API Reference ### Core Types #### `CreatePreAuthUploadUrlPayload` ```typescript type CreatePreAuthUploadUrlPayload = { UploadUrl: string; // Pre-authenticated URL for upload ProviderKey?: string; // Optional provider-specific key for future reference }; ``` #### `GetObjectParams` / `GetObjectMetadataParams` ```typescript type GetObjectParams = { objectId?: string; // Provider-specific ID (preferred for performance) fullPath?: string; // Full path to the object (fallback) }; ``` #### `StorageObjectMetadata` ```typescript type StorageObjectMetadata = { name: string; // Object name (filename) path: string; // Directory path fullPath: string; // Complete path including name size: number; // Size in bytes contentType: string; // MIME type lastModified: Date; // Last modification date isDirectory: boolean; // Whether this is a directory etag?: string; // Entity tag for caching cacheControl?: string; // Cache control directives customMetadata?: Record; }; ``` #### `StorageListResult` ```typescript type StorageListResult = { objects: StorageObjectMetadata[]; // Files found prefixes: string[]; // Directories found }; ``` #### `FileSearchOptions` ```typescript type FileSearchOptions = { maxResults?: number; // Maximum results (default: 100) fileTypes?: string[]; // Filter by MIME types or extensions modifiedAfter?: Date; // Only files modified after this date modifiedBefore?: Date; // Only files modified before this date pathPrefix?: string; // Search within specific directory searchContent?: boolean; // Search file contents (default: false) providerSpecific?: Record;// Provider-specific options }; ``` #### `FileSearchResult` ```typescript type FileSearchResult = { path: string; // Full path to file name: string; // Filename only size: number; // Size in bytes contentType: string; // MIME type lastModified: Date; // Last modification date relevance?: number; // Relevance score (0.0-1.0) excerpt?: string; // Text excerpt with match context matchInFilename?: boolean; // Whether match is in filename objectId?: string; // Provider-specific ID for direct access customMetadata?: Record; providerData?: Record; }; ``` #### `FileSearchResultSet` ```typescript type FileSearchResultSet = { results: FileSearchResult[]; // Array of matching files totalMatches?: number; // Total matches (if available) hasMore: boolean; // More results available? nextPageToken?: string; // Token for next page }; ``` #### `StorageProviderConfig` ```typescript interface StorageProviderConfig { accountId?: string; // FileStorageAccount ID (multi-tenant tracking) accountName?: string; // Account display name (logging) [key: string]: unknown; // Provider-specific configuration values } ``` #### `UnsupportedOperationError` Custom error class thrown when a provider does not support a specific operation (e.g., `SearchFiles` on AWS S3). ### FileStorageBase Methods All storage providers implement these methods: | Method | Returns | Description | |---|---|---| | `initialize(config?)` | `Promise` | Initialize the driver. Always call after construction. | | `CreatePreAuthUploadUrl(objectName)` | `Promise` | Generate pre-authenticated upload URL | | `CreatePreAuthDownloadUrl(objectName)` | `Promise` | Generate pre-authenticated download URL | | `MoveObject(oldName, newName)` | `Promise` | Move/rename a file | | `DeleteObject(objectName)` | `Promise` | Delete a file | | `ListObjects(prefix, delimiter?)` | `Promise` | List files and directories | | `CreateDirectory(directoryPath)` | `Promise` | Create a directory | | `DeleteDirectory(path, recursive?)` | `Promise` | Delete a directory | | `GetObjectMetadata(params)` | `Promise` | Get file metadata without downloading | | `GetObject(params)` | `Promise` | Download full file content into memory | | `get SupportsStreaming` | `boolean` | Whether the provider supports `GetObjectStream` (all seven built-in drivers: `true`) | | `GetObjectStream(params)` | `Promise` | Stream file content (optionally a byte `Range`) without buffering; throws `StreamingNotSupportedError` if unsupported. See [docs/STREAMING.md](docs/STREAMING.md) | | `PutObject(name, data, contentType?, metadata?)` | `Promise` | Upload file content directly | | `CopyObject(source, destination)` | `Promise` | Copy a file | | `ObjectExists(objectName)` | `Promise` | Check if a file exists | | `DirectoryExists(directoryPath)` | `Promise` | Check if a directory exists | | `SearchFiles(query, options?)` | `Promise` | Search files (throws `UnsupportedOperationError` if not supported) | | `get IsConfigured` | `boolean` | Check if driver is properly configured | | `get AccountId` | `string \| undefined` | Get the associated account ID | | `get AccountName` | `string \| undefined` | Get the associated account name | ### Utility Functions High-level functions that integrate with MemberJunction's entity system: | Function | Description | |---|---| | `createUploadUrl(provider, input, userContext?)` | Create pre-authenticated upload URL with automatic MIME type detection | | `createDownloadUrl(provider, keyOrName, userContext?)` | Create pre-authenticated download URL | | `moveObject(provider, oldKey, newKey, userContext?)` | Move a file within a provider | | `copyObject(provider, source, destination, userContext?)` | Copy a file within a provider | | `deleteObject(provider, keyOrName, userContext?)` | Delete a file | | `listObjects(provider, prefix, delimiter?, userContext?)` | List files and directories | | `copyObjectBetweenProviders(source, dest, sourcePath, destPath, options?)` | Transfer file between providers | | `searchAcrossProviders(providers, query, options?)` | Search multiple providers in parallel | | `searchAcrossAccounts(accounts, query, options)` | Search multiple accounts in parallel (enterprise) | | `initializeDriverWithAccountCredentials(options)` | Initialize driver using enterprise credential model | | `initializeDriverWithUserCredentials(options)` | Initialize driver with user context (deprecated) | ### Configuration Functions | Function | Description | |---|---| | `getStorageConfig()` | Get full storage configuration (loads from `mj.config.cjs` on first call) | | `getStorageProvidersConfig()` | Get just the storage providers configuration | | `getProviderConfig(provider)` | Get configuration for a specific provider by key | | `clearStorageConfig()` | Clear cached configuration (useful for testing) | ## Implementing Additional Providers The library is designed to be extensible. To add a new storage provider: ### 1. Create a New Provider Class ```typescript import { FileStorageBase, StorageProviderConfig, StorageObjectMetadata, StorageListResult, CreatePreAuthUploadUrlPayload, GetObjectParams, GetObjectMetadataParams, FileSearchOptions, FileSearchResultSet, } from '@memberjunction/storage'; import { RegisterClass } from '@memberjunction/global'; @RegisterClass(FileStorageBase, 'My Custom Storage') export class MyCustomStorage extends FileStorageBase { protected readonly providerName = 'My Custom Storage'; private _apiKey: string | undefined; private _isConfigured = false; constructor() { super(); // Load from environment variables this._apiKey = process.env.STORAGE_MYCUSTOM_API_KEY; } public async initialize(config?: StorageProviderConfig): Promise { await super.initialize(config); // Sets accountId and accountName if (config) { // Override env var defaults with config values if present if (config.apiKey) this._apiKey = config.apiKey as string; } this._isConfigured = !!this._apiKey; } public get IsConfigured(): boolean { return this._isConfigured; } // Implement all abstract methods... public async SearchFiles(query: string, options?: FileSearchOptions): Promise { // If not supported: this.throwUnsupportedOperationError('SearchFiles'); } } ``` ### 2. Export from Index Add to `src/index.ts`: ```typescript export * from './drivers/MyCustomStorage'; ``` ## Class Hierarchy ```mermaid classDiagram class FileStorageBase { <> #providerName: string #_accountId: string #_accountName: string +initialize(config?) Promise~void~ +get IsConfigured() boolean +get AccountId() string +get AccountName() string +CreatePreAuthUploadUrl(name) Promise +CreatePreAuthDownloadUrl(name) Promise +MoveObject(old, new) Promise +DeleteObject(name) Promise +ListObjects(prefix, delimiter?) Promise +CreateDirectory(path) Promise +DeleteDirectory(path, recursive?) Promise +GetObjectMetadata(params) Promise +GetObject(params) Promise +PutObject(name, data, type?, meta?) Promise +CopyObject(source, dest) Promise +ObjectExists(name) Promise +DirectoryExists(path) Promise +SearchFiles(query, options?) Promise #throwUnsupportedOperationError(method) never } class AWSFileStorage { @RegisterClass 'AWS S3' } class AzureFileStorage { @RegisterClass 'Azure Blob Storage' } class GoogleFileStorage { @RegisterClass 'Google Cloud Storage' } class GoogleDriveFileStorage { @RegisterClass 'Google Drive Storage' } class SharePointFileStorage { @RegisterClass 'SharePoint' } class DropboxFileStorage { @RegisterClass 'Dropbox' } class BoxFileStorage { @RegisterClass 'Box' } FileStorageBase <|-- AWSFileStorage FileStorageBase <|-- AzureFileStorage FileStorageBase <|-- GoogleFileStorage FileStorageBase <|-- GoogleDriveFileStorage FileStorageBase <|-- SharePointFileStorage FileStorageBase <|-- DropboxFileStorage FileStorageBase <|-- BoxFileStorage ``` ## Error Handling ### UnsupportedOperationError Thrown when a provider does not support a specific operation: ```typescript try { await storage.SearchFiles('quarterly report'); } catch (error) { if (error instanceof UnsupportedOperationError) { console.log(`Provider doesn't support search: ${error.message}`); // Fall back to ListObjects with client-side filtering } } ``` ### Provider-Specific Errors Each provider may throw errors specific to its underlying SDK. These are not wrapped, allowing you to handle provider-specific error conditions: ```typescript try { await storage.GetObject({ fullPath: 'non-existent-file.txt' }); } catch (error) { // Handle provider-specific errors if (error.code === 'NoSuchKey') { // AWS S3 console.log('File not found'); } else if (error.code === 'BlobNotFound') { // Azure console.log('Blob not found'); } } ``` ## Testing The package includes Jest-based unit tests for the base class behavior and the enterprise credential initialization flow. ```bash # Run tests npm test # Run tests in watch mode npm run test:watch # Run tests with coverage npm run test:coverage ``` Test files are located at: - `src/__tests__/FileStorageBase.test.ts` -- Tests for the `FileStorageBase` abstract class, `initialize()` method, and account information handling - `src/__tests__/util.test.ts` -- Tests for `initializeDriverWithAccountCredentials` and the enterprise credential model ## Build ```bash # Build the package npm run build # Watch mode for development npm run watch ``` The package uses TypeScript with `tsc` and `tsc-alias` for path alias resolution. Output is emitted to the `dist/` directory. ## Best Practices 1. **Always call `initialize()`**: After creating a provider instance, always call `initialize()` before using the driver, even if no configuration is needed. 2. **Use ProviderKey**: Always check for and use `ProviderKey` if returned by `CreatePreAuthUploadUrl` for subsequent operations. 3. **Use `objectId` when available**: The `GetObject` and `GetObjectMetadata` methods accept either `objectId` or `fullPath`. Using `objectId` bypasses path resolution and is significantly faster. 4. **Use enterprise credential model**: Prefer `initializeDriverWithAccountCredentials` over direct instantiation for multi-tenant applications. 5. **Handle unsupported operations**: Wrap `SearchFiles` calls in try/catch for `UnsupportedOperationError` since not all providers support search. 6. **Directory paths**: Always use trailing slashes for directory paths (e.g., `documents/` not `documents`). 7. **Content types**: Always specify content types for better browser handling and security. 8. **Buffer operations**: `GetObject` and `PutObject` load entire files into memory; consider pre-authenticated URLs for large files. 9. **Batch searches**: Use `searchAcrossProviders` or `searchAcrossAccounts` for parallel multi-source search instead of sequential calls. ## License ISC