Skip to content

Commit 0baaf06

Browse files
authored
feat(storage): align analytics from method with { data, error } pattern (#1927)
1 parent 21e1008 commit 0baaf06

File tree

4 files changed

+92
-64
lines changed

4 files changed

+92
-64
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/storage-js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"docs:json": "typedoc --json docs/v2/spec.json --entryPoints src/index.ts --entryPoints src/packages/* --excludePrivate --excludeExternals --excludeProtected"
3838
},
3939
"dependencies": {
40-
"iceberg-js": "^0.8.0",
40+
"iceberg-js": "^0.8.1",
4141
"tslib": "2.8.1"
4242
},
4343
"devDependencies": {

packages/core/storage-js/src/packages/StorageAnalyticsClient.ts

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { IcebergRestCatalog } from 'iceberg-js'
1+
import { IcebergRestCatalog, IcebergError } from 'iceberg-js'
22
import { DEFAULT_HEADERS } from '../lib/constants'
33
import { isStorageError, StorageError } from '../lib/errors'
44
import { Fetch, get, post, remove } from '../lib/fetch'
55
import { isValidBucketName, resolveFetch } from '../lib/helpers'
66
import { AnalyticBucket } from '../lib/types'
77

8+
type WrapAsyncMethod<T> = T extends (...args: infer A) => Promise<infer R>
9+
? (...args: A) => Promise<{ data: R; error: null } | { data: null; error: IcebergError }>
10+
: T
11+
12+
export type WrappedIcebergRestCatalog = {
13+
[K in keyof IcebergRestCatalog]: WrapAsyncMethod<IcebergRestCatalog[K]>
14+
}
15+
816
/**
917
* Client class for managing Analytics Buckets using Iceberg tables
1018
* Provides methods for creating, listing, and deleting analytics buckets
@@ -269,12 +277,14 @@ export default class StorageAnalyticsClient {
269277
* Get an Iceberg REST Catalog client configured for a specific analytics bucket
270278
* Use this to perform advanced table and namespace operations within the bucket
271279
* The returned client provides full access to the Apache Iceberg REST Catalog API
280+
* with the Supabase `{ data, error }` pattern for consistent error handling on all operations.
272281
*
273282
* **Public alpha:** This API is part of a public alpha release and may not be available to your account type.
274283
*
275284
* @category Analytics Buckets
276285
* @param bucketName - The name of the analytics bucket (warehouse) to connect to
277-
* @returns Configured IcebergRestCatalog instance for advanced Iceberg operations
286+
* @returns The wrapped Iceberg catalog client
287+
* @throws {StorageError} If the bucket name is invalid
278288
*
279289
* @example Get catalog and create table
280290
* ```js
@@ -288,10 +298,10 @@ export default class StorageAnalyticsClient {
288298
* const catalog = supabase.storage.analytics.from('analytics-data')
289299
*
290300
* // Create a namespace
291-
* await catalog.createNamespace({ namespace: ['default'] })
301+
* const { error: nsError } = await catalog.createNamespace({ namespace: ['default'] })
292302
*
293303
* // Create a table with schema
294-
* await catalog.createTable(
304+
* const { data: tableMetadata, error: tableError } = await catalog.createTable(
295305
* { namespace: ['default'] },
296306
* {
297307
* name: 'events',
@@ -325,7 +335,13 @@ export default class StorageAnalyticsClient {
325335
* const catalog = supabase.storage.analytics.from('analytics-data')
326336
*
327337
* // List all tables in the default namespace
328-
* const tables = await catalog.listTables({ namespace: ['default'] })
338+
* const { data: tables, error: listError } = await catalog.listTables({ namespace: ['default'] })
339+
* if (listError) {
340+
* if (listError.isNotFound()) {
341+
* console.log('Namespace not found')
342+
* }
343+
* return
344+
* }
329345
* console.log(tables) // [{ namespace: ['default'], name: 'events' }]
330346
* ```
331347
*
@@ -334,7 +350,7 @@ export default class StorageAnalyticsClient {
334350
* const catalog = supabase.storage.analytics.from('analytics-data')
335351
*
336352
* // List all namespaces
337-
* const namespaces = await catalog.listNamespaces()
353+
* const { data: namespaces } = await catalog.listNamespaces()
338354
*
339355
* // Create namespace with properties
340356
* await catalog.createNamespace(
@@ -348,57 +364,37 @@ export default class StorageAnalyticsClient {
348364
* const catalog = supabase.storage.analytics.from('analytics-data')
349365
*
350366
* // Drop table with purge option (removes all data)
351-
* await catalog.dropTable(
367+
* const { error: dropError } = await catalog.dropTable(
352368
* { namespace: ['default'], name: 'events' },
353369
* { purge: true }
354370
* )
355371
*
372+
* if (dropError?.isNotFound()) {
373+
* console.log('Table does not exist')
374+
* }
375+
*
356376
* // Drop namespace (must be empty)
357377
* await catalog.dropNamespace({ namespace: ['default'] })
358378
* ```
359379
*
360-
* @example Error handling with catalog operations
361-
* ```js
362-
* import { IcebergError } from 'iceberg-js'
363-
*
364-
* const catalog = supabase.storage.analytics.from('analytics-data')
365-
*
366-
* try {
367-
* await catalog.dropTable({ namespace: ['default'], name: 'events' }, { purge: true })
368-
* } catch (error) {
369-
* // Handle 404 errors (resource not found)
370-
* const is404 =
371-
* (error instanceof IcebergError && error.status === 404) ||
372-
* error?.status === 404 ||
373-
* error?.details?.error?.code === 404
374-
*
375-
* if (is404) {
376-
* console.log('Table does not exist')
377-
* } else {
378-
* throw error // Re-throw other errors
379-
* }
380-
* }
381-
* ```
382-
*
383380
* @remarks
384381
* This method provides a bridge between Supabase's bucket management and the standard
385382
* Apache Iceberg REST Catalog API. The bucket name maps to the Iceberg warehouse parameter.
386383
* All authentication and configuration is handled automatically using your Supabase credentials.
387384
*
388-
* **Error Handling**: Operations may throw `IcebergError` from the iceberg-js library.
389-
* Always handle 404 errors gracefully when checking for resource existence.
385+
* **Error Handling**: Invalid bucket names throw immediately. All catalog
386+
* operations return `{ data, error }` where errors are `IcebergError` instances from iceberg-js.
387+
* Use helper methods like `error.isNotFound()` or check `error.status` for specific error handling.
388+
* Use `.throwOnError()` on the analytics client if you prefer exceptions for catalog operations.
390389
*
391390
* **Cleanup Operations**: When using `dropTable`, the `purge: true` option permanently
392391
* deletes all table data. Without it, the table is marked as deleted but data remains.
393392
*
394-
* **Library Dependency**: The returned catalog is an instance of `IcebergRestCatalog`
395-
* from iceberg-js. For complete API documentation and advanced usage, refer to the
393+
* **Library Dependency**: The returned catalog wraps `IcebergRestCatalog` from iceberg-js.
394+
* For complete API documentation and advanced usage, refer to the
396395
* [iceberg-js documentation](https://supabase.github.io/iceberg-js/).
397-
*
398-
* For advanced Iceberg operations beyond bucket management, you can also install and use
399-
* the `iceberg-js` package directly with manual configuration.
400396
*/
401-
from(bucketName: string): IcebergRestCatalog {
397+
from(bucketName: string): WrappedIcebergRestCatalog {
402398
// Validate bucket name using same rules as Supabase Storage API backend
403399
if (!isValidBucketName(bucketName)) {
404400
throw new StorageError(
@@ -411,7 +407,7 @@ export default class StorageAnalyticsClient {
411407
// The base URL is /storage/v1/iceberg
412408
// Note: IcebergRestCatalog from iceberg-js automatically adds /v1/ prefix to API paths
413409
// so we should NOT append /v1 here (it would cause double /v1/v1/ in the URL)
414-
return new IcebergRestCatalog({
410+
const catalog = new IcebergRestCatalog({
415411
baseUrl: this.url,
416412
catalogName: bucketName, // Maps to the warehouse parameter in Supabase's implementation
417413
auth: {
@@ -420,5 +416,30 @@ export default class StorageAnalyticsClient {
420416
},
421417
fetch: this.fetch,
422418
})
419+
420+
const shouldThrowOnError = this.shouldThrowOnError
421+
422+
const wrappedCatalog = new Proxy(catalog, {
423+
get(target, prop: keyof IcebergRestCatalog) {
424+
const value = target[prop]
425+
if (typeof value !== 'function') {
426+
return value
427+
}
428+
429+
return async (...args: unknown[]) => {
430+
try {
431+
const data = await (value as Function).apply(target, args)
432+
return { data, error: null }
433+
} catch (error) {
434+
if (shouldThrowOnError) {
435+
throw error
436+
}
437+
return { data: null, error: error as IcebergError }
438+
}
439+
}
440+
},
441+
}) as unknown as WrappedIcebergRestCatalog
442+
443+
return wrappedCatalog
423444
}
424445
}

packages/core/storage-js/test/analytics-getcatalog.test.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,40 @@
11
/**
22
* Unit tests for StorageAnalyticsClient.from() method
3-
* Tests that the method returns a properly configured IcebergRestCatalog instance
3+
* Tests that the method returns a wrapped catalog directly and throws on invalid bucket names
44
*/
55

6-
import { IcebergRestCatalog } from 'iceberg-js'
76
import StorageAnalyticsClient from '../src/packages/StorageAnalyticsClient'
87
import { StorageError } from '../src/lib/errors'
98

109
describe('StorageAnalyticsClient.from()', () => {
11-
it('should return an IcebergRestCatalog instance', () => {
10+
it('should return catalog directly for valid bucket name', () => {
1211
const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {
1312
Authorization: 'Bearer test-token',
1413
})
1514

1615
const catalog = client.from('my-analytics-bucket')
1716

18-
expect(catalog).toBeInstanceOf(IcebergRestCatalog)
17+
expect(catalog).not.toBeNull()
18+
expect(typeof catalog.listNamespaces).toBe('function')
1919
})
2020

21-
it('should return different instances for different bucket names', () => {
21+
it('should return different catalog instances for different bucket names', () => {
2222
const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {})
2323

2424
const catalog1 = client.from('bucket-1')
2525
const catalog2 = client.from('bucket-2')
2626

27-
expect(catalog1).toBeInstanceOf(IcebergRestCatalog)
28-
expect(catalog2).toBeInstanceOf(IcebergRestCatalog)
27+
expect(catalog1).not.toBeNull()
28+
expect(catalog2).not.toBeNull()
2929
expect(catalog1).not.toBe(catalog2) // Different instances
3030
})
3131

32-
it('should work with minimal configuration', () => {
32+
it('should return catalog with all expected methods', () => {
3333
const client = new StorageAnalyticsClient('http://localhost:8181', {})
3434

3535
const catalog = client.from('test-warehouse')
3636

37-
expect(catalog).toBeInstanceOf(IcebergRestCatalog)
37+
expect(catalog).not.toBeNull()
3838
expect(typeof catalog.listNamespaces).toBe('function')
3939
expect(typeof catalog.createNamespace).toBe('function')
4040
expect(typeof catalog.createTable).toBe('function')
@@ -45,12 +45,20 @@ describe('StorageAnalyticsClient.from()', () => {
4545
expect(typeof catalog.dropNamespace).toBe('function')
4646
})
4747

48-
it('should work when called from throwOnError chain', () => {
48+
it('should always throw on invalid bucket name (regardless of throwOnError)', () => {
49+
const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {})
50+
51+
// Invalid bucket name always throws - it's a programmer error
52+
expect(() => client.from('bucket/invalid')).toThrow(StorageError)
53+
expect(() => client.throwOnError().from('bucket/invalid')).toThrow(StorageError)
54+
})
55+
56+
it('should return catalog when called from throwOnError chain with valid bucket', () => {
4957
const client = new StorageAnalyticsClient('https://example.supabase.co/storage/v1/iceberg', {})
5058

5159
const catalog = client.throwOnError().from('my-bucket')
5260

53-
expect(catalog).toBeInstanceOf(IcebergRestCatalog)
61+
expect(catalog).not.toBeNull()
5462
})
5563

5664
describe('bucket name validation', () => {
@@ -82,38 +90,37 @@ describe('StorageAnalyticsClient.from()', () => {
8290
})
8391

8492
describe('invalid bucket names', () => {
85-
it('should reject empty or null bucket names', () => {
93+
it('should throw for empty or null bucket names', () => {
8694
expect(() => client.from('')).toThrow(StorageError)
8795
expect(() => client.from(null as any)).toThrow(StorageError)
8896
expect(() => client.from(undefined as any)).toThrow(StorageError)
8997
})
9098

91-
it('should reject path traversal with slashes', () => {
99+
it('should throw for path traversal with slashes', () => {
92100
expect(() => client.from('../etc/passwd')).toThrow(StorageError)
93101
expect(() => client.from('bucket/../other')).toThrow(StorageError)
94-
// Note: '..' alone is valid (just two periods), only with slashes is it path traversal
95102
})
96103

97-
it('should reject names with path separators', () => {
104+
it('should throw for names with path separators', () => {
98105
expect(() => client.from('bucket/nested')).toThrow(StorageError)
99106
expect(() => client.from('/bucket')).toThrow(StorageError)
100107
expect(() => client.from('bucket/')).toThrow(StorageError)
101108
expect(() => client.from('bucket\\nested')).toThrow(StorageError)
102109
expect(() => client.from('path\\to\\bucket')).toThrow(StorageError)
103110
})
104111

105-
it('should reject names with leading or trailing whitespace', () => {
112+
it('should throw for names with leading or trailing whitespace', () => {
106113
expect(() => client.from(' bucket')).toThrow(StorageError)
107114
expect(() => client.from('bucket ')).toThrow(StorageError)
108115
expect(() => client.from(' bucket ')).toThrow(StorageError)
109116
})
110117

111-
it('should reject names exceeding 100 characters', () => {
118+
it('should throw for names exceeding 100 characters', () => {
112119
const tooLongName = 'a'.repeat(101)
113120
expect(() => client.from(tooLongName)).toThrow(StorageError)
114121
})
115122

116-
it('should reject names with unsafe special characters', () => {
123+
it('should throw for names with unsafe special characters', () => {
117124
expect(() => client.from('bucket{name}')).toThrow(StorageError)
118125
expect(() => client.from('bucket[name]')).toThrow(StorageError)
119126
expect(() => client.from('bucket<name>')).toThrow(StorageError)
@@ -124,7 +131,7 @@ describe('StorageAnalyticsClient.from()', () => {
124131
it('should provide clear error messages', () => {
125132
try {
126133
client.from('bucket/nested')
127-
fail('Should have thrown an error')
134+
fail('Expected to throw')
128135
} catch (error) {
129136
expect(error).toBeInstanceOf(StorageError)
130137
expect((error as StorageError).message).toContain('Invalid bucket name')
@@ -134,7 +141,7 @@ describe('StorageAnalyticsClient.from()', () => {
134141
})
135142

136143
describe('URL encoding behavior', () => {
137-
it('should reject strings with percent signs (URL encoding)', () => {
144+
it('should throw for strings with percent signs (URL encoding)', () => {
138145
// The % character is not in the allowed character set, so URL-encoded
139146
// strings will be rejected. This is correct behavior - users should
140147
// pass unencoded bucket names.

0 commit comments

Comments
 (0)