import { IcebergRestCatalog, IcebergError } from 'iceberg-js' import { DEFAULT_HEADERS } from '../lib/constants' import { StorageError } from '../lib/common/errors' import { Fetch, get, post, remove } from '../lib/common/fetch' import { isValidBucketName } from '../lib/common/helpers' import BaseApiClient from '../lib/common/BaseApiClient' import { AnalyticBucket } from '../lib/types' type WrapAsyncMethod = T extends (...args: infer A) => Promise ? (...args: A) => Promise<{ data: R; error: null } | { data: null; error: IcebergError }> : T export type WrappedIcebergRestCatalog = { [K in keyof IcebergRestCatalog]: WrapAsyncMethod } /** * Client class for managing Analytics Buckets using Iceberg tables * Provides methods for creating, listing, and deleting analytics buckets */ export default class StorageAnalyticsClient extends BaseApiClient { /** * @alpha * * Creates a new StorageAnalyticsClient instance * * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. * * @category Storage * @subcategory Analytics Buckets * @param url - The base URL for the storage API * @param headers - HTTP headers to include in requests * @param fetch - Optional custom fetch implementation * * @example Using supabase-js (recommended) * ```typescript * import { createClient } from '@supabase/supabase-js' * * const supabase = createClient('https://xyzcompany.supabase.co', 'your-publishable-key') * const { data, error } = await supabase.storage.analytics.listBuckets() * ``` * * @example Standalone import for bundle-sensitive environments * ```typescript * import { StorageAnalyticsClient } from '@supabase/storage-js' * * const client = new StorageAnalyticsClient(url, headers) * ``` */ constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { const finalUrl = url.replace(/\/$/, '') const finalHeaders = { ...DEFAULT_HEADERS, ...headers } super(finalUrl, finalHeaders, fetch, 'storage') } /** * @alpha * * Creates a new analytics bucket using Iceberg tables * Analytics buckets are optimized for analytical queries and data processing * * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. * * @category Storage * @subcategory Analytics Buckets * @param name A unique name for the bucket you are creating * @returns Promise with response containing newly created analytics bucket or error * * @example Create analytics bucket * ```js * const { data, error } = await supabase * .storage * .analytics * .createBucket('analytics-data') * ``` * * Response: * ```json * { * "data": { * "name": "analytics-data", * "type": "ANALYTICS", * "format": "iceberg", * "created_at": "2024-05-22T22:26:05.100Z", * "updated_at": "2024-05-22T22:26:05.100Z" * }, * "error": null * } * ``` * * @remarks * - Creates a new analytics bucket using Iceberg tables * - Analytics buckets are optimized for analytical queries and data processing */ async createBucket(name: string): Promise< | { data: AnalyticBucket error: null } | { data: null error: StorageError } > { return this.handleOperation(async () => { return await post(this.fetch, `${this.url}/bucket`, { name }, { headers: this.headers }) }) } /** * @alpha * * Retrieves the details of all Analytics Storage buckets within an existing project * Only returns buckets of type 'ANALYTICS' * * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. * * @category Storage * @subcategory Analytics Buckets * @param options Query parameters for listing buckets * @param options.limit Maximum number of buckets to return * @param options.offset Number of buckets to skip * @param options.sortColumn Column to sort by ('name', 'created_at', 'updated_at') * @param options.sortOrder Sort order ('asc' or 'desc') * @param options.search Search term to filter bucket names * @returns Promise with response containing array of analytics buckets or error * * @example List analytics buckets * ```js * const { data, error } = await supabase * .storage * .analytics * .listBuckets({ * limit: 10, * offset: 0, * sortColumn: 'created_at', * sortOrder: 'desc' * }) * ``` * * Response: * ```json * { * "data": [ * { * "name": "analytics-data", * "type": "ANALYTICS", * "format": "iceberg", * "created_at": "2024-05-22T22:26:05.100Z", * "updated_at": "2024-05-22T22:26:05.100Z" * } * ], * "error": null * } * ``` * * @remarks * - Retrieves the details of all Analytics Storage buckets within an existing project * - Only returns buckets of type 'ANALYTICS' */ async listBuckets(options?: { limit?: number offset?: number sortColumn?: 'name' | 'created_at' | 'updated_at' sortOrder?: 'asc' | 'desc' search?: string }): Promise< | { data: AnalyticBucket[] error: null } | { data: null error: StorageError } > { return this.handleOperation(async () => { // Build query string from options const queryParams = new URLSearchParams() if (options?.limit !== undefined) queryParams.set('limit', options.limit.toString()) if (options?.offset !== undefined) queryParams.set('offset', options.offset.toString()) if (options?.sortColumn) queryParams.set('sortColumn', options.sortColumn) if (options?.sortOrder) queryParams.set('sortOrder', options.sortOrder) if (options?.search) queryParams.set('search', options.search) const queryString = queryParams.toString() const url = queryString ? `${this.url}/bucket?${queryString}` : `${this.url}/bucket` return await get(this.fetch, url, { headers: this.headers }) }) } /** * @alpha * * Deletes an existing analytics bucket * A bucket can't be deleted with existing objects inside it * You must first empty the bucket before deletion * * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. * * @category Storage * @subcategory Analytics Buckets * @param bucketName The unique identifier of the bucket you would like to delete * @returns Promise with response containing success message or error * * @example Delete analytics bucket * ```js * const { data, error } = await supabase * .storage * .analytics * .deleteBucket('analytics-data') * ``` * * Response: * ```json * { * "data": { * "message": "Successfully deleted" * }, * "error": null * } * ``` * * @remarks * - Deletes an analytics bucket */ async deleteBucket(bucketName: string): Promise< | { data: { message: string } error: null } | { data: null error: StorageError } > { return this.handleOperation(async () => { return await remove( this.fetch, `${this.url}/bucket/${bucketName}`, {}, { headers: this.headers } ) }) } /** * @alpha * * Get an Iceberg REST Catalog client configured for a specific analytics bucket * Use this to perform advanced table and namespace operations within the bucket * The returned client provides full access to the Apache Iceberg REST Catalog API * with the Supabase `{ data, error }` pattern for consistent error handling on all operations. * * **Public alpha:** This API is part of a public alpha release and may not be available to your account type. * * @category Storage * @subcategory Analytics Buckets * @param bucketName - The name of the analytics bucket (warehouse) to connect to * @returns The wrapped Iceberg catalog client * @throws {StorageError} If the bucket name is invalid * * @example Get catalog and create table * ```js * // First, create an analytics bucket * const { data: bucket, error: bucketError } = await supabase * .storage * .analytics * .createBucket('analytics-data') * * // Get the Iceberg catalog for that bucket * const catalog = supabase.storage.analytics.from('analytics-data') * * // Create a namespace * const { error: nsError } = await catalog.createNamespace({ namespace: ['default'] }) * * // Create a table with schema * const { data: tableMetadata, error: tableError } = await catalog.createTable( * { namespace: ['default'] }, * { * name: 'events', * schema: { * type: 'struct', * fields: [ * { id: 1, name: 'id', type: 'long', required: true }, * { id: 2, name: 'timestamp', type: 'timestamp', required: true }, * { id: 3, name: 'user_id', type: 'string', required: false } * ], * 'schema-id': 0, * 'identifier-field-ids': [1] * }, * 'partition-spec': { * 'spec-id': 0, * fields: [] * }, * 'write-order': { * 'order-id': 0, * fields: [] * }, * properties: { * 'write.format.default': 'parquet' * } * } * ) * ``` * * @example List tables in namespace * ```js * const catalog = supabase.storage.analytics.from('analytics-data') * * // List all tables in the default namespace * const { data: tables, error: listError } = await catalog.listTables({ namespace: ['default'] }) * if (listError) { * if (listError.isNotFound()) { * console.log('Namespace not found') * } * return * } * console.log(tables) // [{ namespace: ['default'], name: 'events' }] * ``` * * @example Working with namespaces * ```js * const catalog = supabase.storage.analytics.from('analytics-data') * * // List all namespaces * const { data: namespaces } = await catalog.listNamespaces() * * // Create namespace with properties * await catalog.createNamespace( * { namespace: ['production'] }, * { properties: { owner: 'data-team', env: 'prod' } } * ) * ``` * * @example Cleanup operations * ```js * const catalog = supabase.storage.analytics.from('analytics-data') * * // Drop table with purge option (removes all data) * const { error: dropError } = await catalog.dropTable( * { namespace: ['default'], name: 'events' }, * { purge: true } * ) * * if (dropError?.isNotFound()) { * console.log('Table does not exist') * } * * // Drop namespace (must be empty) * await catalog.dropNamespace({ namespace: ['default'] }) * ``` * * @remarks * This method provides a bridge between Supabase's bucket management and the standard * Apache Iceberg REST Catalog API. The bucket name maps to the Iceberg warehouse parameter. * All authentication and configuration is handled automatically using your Supabase credentials. * * **Error Handling**: Invalid bucket names throw immediately. All catalog * operations return `{ data, error }` where errors are `IcebergError` instances from iceberg-js. * Use helper methods like `error.isNotFound()` or check `error.status` for specific error handling. * Use `.throwOnError()` on the analytics client if you prefer exceptions for catalog operations. * * **Cleanup Operations**: When using `dropTable`, the `purge: true` option permanently * deletes all table data. Without it, the table is marked as deleted but data remains. * * **Library Dependency**: The returned catalog wraps `IcebergRestCatalog` from iceberg-js. * For complete API documentation and advanced usage, refer to the * [iceberg-js documentation](https://supabase.github.io/iceberg-js/). */ from(bucketName: string): WrappedIcebergRestCatalog { // Validate bucket name using same rules as Supabase Storage API backend if (!isValidBucketName(bucketName)) { throw new StorageError( 'Invalid bucket name: File, folder, and bucket names must follow AWS object key naming guidelines ' + 'and should avoid the use of any other characters.' ) } // Construct the Iceberg REST Catalog URL // The base URL is /storage/v1/iceberg // Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths // so we should NOT append /v1 here (it would cause double /v1/v1/ in the URL) const catalog = new IcebergRestCatalog({ baseUrl: this.url, catalogName: bucketName, // Maps to the warehouse parameter in Supabase's implementation auth: { type: 'custom', getHeaders: async () => this.headers, }, fetch: this.fetch, }) const shouldThrowOnError = this.shouldThrowOnError const wrappedCatalog = new Proxy(catalog, { get(target, prop: keyof IcebergRestCatalog) { const value = target[prop] if (typeof value !== 'function') { return value } return async (...args: unknown[]) => { try { const data = await (value as Function).apply(target, args) return { data, error: null } } catch (error) { if (shouldThrowOnError) { throw error } return { data: null, error: error as IcebergError } } } }, }) as unknown as WrappedIcebergRestCatalog return wrappedCatalog } }