From 551e3f04e422f9465c1a2da6351b667900d57266 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 20 May 2026 00:45:53 -0300 Subject: [PATCH] feat: update supabase-storage to 2.106.0 --- .../supabase-storage/COMPATIBILITY.md | 46 ++ .../supabase-storage/StorageClient.ts | 32 +- .../supabase-storage/index.ts | 16 +- .../supabase-storage/lib/StorageBucketApi.ts | 231 ------- .../supabase-storage/lib/StorageFileApi.ts | 646 ------------------ .../lib/common/BaseApiClient.ts | 44 ++ .../supabase-storage/lib/common/errors.ts | 101 +++ .../supabase-storage/lib/common/fetch.ts | 154 +++++ .../supabase-storage/lib/common/headers.ts | 21 + .../supabase-storage/lib/common/helpers.ts | 70 ++ .../supabase-storage/lib/errors.ts | 40 -- .../supabase-storage/lib/fetch.ts | 74 -- .../supabase-storage/lib/helpers.ts | 20 - .../supabase-storage/lib/index.ts | 4 +- .../supabase-storage/lib/types.ts | 327 +++++++-- .../supabase-storage/package.json | 20 +- .../packages/BlobDownloadBuilder.ts | 57 ++ .../packages/StorageAnalyticsClient.ts | 117 ++++ .../packages/StorageBucketApi.ts | 179 +++++ .../packages/StorageFileApi.ts | 557 +++++++++++++++ .../packages/StorageVectorsClient.ts | 122 ++++ .../packages/StreamDownloadBuilder.ts | 52 ++ .../packages/VectorBucketApi.ts | 41 ++ .../packages/VectorDataApi.ts | 74 ++ .../packages/VectorIndexApi.ts | 52 ++ 25 files changed, 2002 insertions(+), 1095 deletions(-) create mode 100644 packages/nativescript-supabase/supabase-storage/COMPATIBILITY.md delete mode 100644 packages/nativescript-supabase/supabase-storage/lib/StorageBucketApi.ts delete mode 100644 packages/nativescript-supabase/supabase-storage/lib/StorageFileApi.ts create mode 100644 packages/nativescript-supabase/supabase-storage/lib/common/BaseApiClient.ts create mode 100644 packages/nativescript-supabase/supabase-storage/lib/common/errors.ts create mode 100644 packages/nativescript-supabase/supabase-storage/lib/common/fetch.ts create mode 100644 packages/nativescript-supabase/supabase-storage/lib/common/headers.ts create mode 100644 packages/nativescript-supabase/supabase-storage/lib/common/helpers.ts delete mode 100644 packages/nativescript-supabase/supabase-storage/lib/errors.ts delete mode 100644 packages/nativescript-supabase/supabase-storage/lib/fetch.ts delete mode 100644 packages/nativescript-supabase/supabase-storage/lib/helpers.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/BlobDownloadBuilder.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/StorageAnalyticsClient.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/StorageBucketApi.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/StorageFileApi.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/StorageVectorsClient.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/StreamDownloadBuilder.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/VectorBucketApi.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/VectorDataApi.ts create mode 100644 packages/nativescript-supabase/supabase-storage/packages/VectorIndexApi.ts diff --git a/packages/nativescript-supabase/supabase-storage/COMPATIBILITY.md b/packages/nativescript-supabase/supabase-storage/COMPATIBILITY.md new file mode 100644 index 0000000..2749cd0 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/COMPATIBILITY.md @@ -0,0 +1,46 @@ +# NativeScript Supabase Storage — Upstream Compatibility + +This package is a fork of `@supabase/storage-js` with targeted overrides for NativeScript. The directory structure mirrors upstream (`lib/common/`, `packages/`) to simplify future syncs. + +## What we change + +### File uploads use native HTTP (`StorageFileApi`) + +The `upload()`, `update()`, and `uploadToSignedUrl()` methods bypass the standard `fetch()`-based upload path. Instead, they use `@klippa/nativescript-http`: + +- **`HTTPFormData` / `HTTPFormDataEntry`** replace the browser `FormData` API. +- **Platform-specific file handling:** when `fileBody` is a `string` (file path), it is resolved natively: + - Android: `new java.io.File(fileBody)` + - iOS: `NSData.dataWithContentsOfURL(NSURL.URLWithString(fileBody))` +- **`Http.request()`** is called directly instead of going through the shared `post()`/`put()` fetch helpers. + +All other methods (download, list, move, copy, signed URLs, info, exists, bucket operations, vectors, analytics) use the standard `fetch()`-based code path unchanged. + +### `resolveResponse` / `resolveFetch` (helpers) + +- `resolveResponse` returns the global `Response` directly — the upstream `cross-fetch` dynamic import is removed since NativeScript provides `Response` globally. +- `resolveFetch` has `@ts-ignore` annotations on the spread calls to suppress a TypeScript tuple-type error that only appears under strict NativeScript tsconfig settings. + +## What we do NOT change + +Everything else is a 1:1 copy of upstream, including: + +- `BaseApiClient` (handleOperation, throwOnError, setHeader) +- Error classes and namespace system (storage / vectors) +- Fetch helpers (get, post, put, head, remove, vectorsApi) +- Header normalization +- `StorageBucketApi` (with useNewHostname, ListBucketOptions, BucketType support) +- Non-upload `StorageFileApi` methods (download, info, exists, list, listV2, createSignedUrl, createSignedUrls, getPublicUrl, move, copy, remove) +- `BlobDownloadBuilder` / `StreamDownloadBuilder` +- `StorageAnalyticsClient` (Iceberg) +- `StorageVectorsClient` and all vector API classes +- All types + +## Syncing with upstream + +When updating from a new version of `@supabase/storage-js`: + +1. Diff the upstream `src/` tree against our `lib/` + `packages/` tree. +2. Apply upstream changes to all files **except** the two upload methods in `packages/StorageFileApi.ts` and the two helpers noted above. +3. For the upload methods, merge any new options/fields (e.g. `metadata`, `headers`) into the NativeScript-specific code path while keeping the `Http.request()` / `HTTPFormData` machinery. +4. Run `npx nx run nativescript-supabase:build` to verify. diff --git a/packages/nativescript-supabase/supabase-storage/StorageClient.ts b/packages/nativescript-supabase/supabase-storage/StorageClient.ts index 17df702..1d0e164 100644 --- a/packages/nativescript-supabase/supabase-storage/StorageClient.ts +++ b/packages/nativescript-supabase/supabase-storage/StorageClient.ts @@ -1,18 +1,30 @@ -import StorageFileApi from './lib/StorageFileApi'; -import StorageBucketApi from './lib/StorageBucketApi'; -import { Fetch } from './lib/fetch'; +import StorageFileApi from './packages/StorageFileApi'; +import StorageBucketApi from './packages/StorageBucketApi'; +import StorageAnalyticsClient from './packages/StorageAnalyticsClient'; +import { Fetch } from './lib/common/fetch'; +import { StorageVectorsClient } from './packages/StorageVectorsClient'; + +export interface StorageClientOptions { + useNewHostname?: boolean; +} export class StorageClient extends StorageBucketApi { - constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { - super(url, headers, fetch); + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch, opts?: StorageClientOptions) { + super(url, headers, fetch, opts); } - /** - * Perform file operation in a bucket. - * - * @param id The bucket id to operate on. - */ from(id: string): StorageFileApi { return new StorageFileApi(this.url, this.headers, id, this.fetch); } + + get vectors(): StorageVectorsClient { + return new StorageVectorsClient(this.url + '/vector', { + headers: this.headers, + fetch: this.fetch, + }); + } + + get analytics(): StorageAnalyticsClient { + return new StorageAnalyticsClient(this.url + '/iceberg', this.headers, this.fetch); + } } diff --git a/packages/nativescript-supabase/supabase-storage/index.ts b/packages/nativescript-supabase/supabase-storage/index.ts index dd632f7..17eb858 100644 --- a/packages/nativescript-supabase/supabase-storage/index.ts +++ b/packages/nativescript-supabase/supabase-storage/index.ts @@ -1,3 +1,15 @@ -export { StorageClient as StorageClient } from './StorageClient'; +export { StorageClient } from './StorageClient'; +export type { StorageClientOptions } from './StorageClient'; +export { default as StorageAnalyticsClient } from './packages/StorageAnalyticsClient'; + +// Vector Storage +export { StorageVectorsClient, VectorBucketScope, VectorIndexScope } from './packages/StorageVectorsClient'; +export type { StorageVectorsClientOptions } from './packages/StorageVectorsClient'; +export { default as VectorBucketApi } from './packages/VectorBucketApi'; +export { default as VectorDataApi } from './packages/VectorDataApi'; +export { default as VectorIndexApi } from './packages/VectorIndexApi'; +export type { CreateIndexOptions } from './packages/VectorIndexApi'; + +// Types and Errors export * from './lib/types'; -export * from './lib/errors'; +export * from './lib/common/errors'; diff --git a/packages/nativescript-supabase/supabase-storage/lib/StorageBucketApi.ts b/packages/nativescript-supabase/supabase-storage/lib/StorageBucketApi.ts deleted file mode 100644 index d892244..0000000 --- a/packages/nativescript-supabase/supabase-storage/lib/StorageBucketApi.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { DEFAULT_HEADERS } from './constants'; -import { isStorageError, StorageError } from '../lib/errors'; -import { Fetch, get, post, put, remove } from '../lib/fetch'; -import { resolveFetch } from '../lib/helpers'; -import { Bucket } from '../lib/types'; - -export default class StorageBucketApi { - protected url: string; - protected headers: { [key: string]: string }; - protected fetch: Fetch; - - constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { - this.url = url; - this.headers = { ...DEFAULT_HEADERS, ...headers }; - this.fetch = resolveFetch(fetch); - } - - /** - * Retrieves the details of all Storage buckets within an existing project. - */ - async listBuckets(): Promise< - | { - data: Bucket[]; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await get(this.fetch, `${this.url}/bucket`, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Retrieves the details of an existing Storage bucket. - * - * @param id The unique identifier of the bucket you would like to retrieve. - */ - async getBucket(id: string): Promise< - | { - data: Bucket; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await get(this.fetch, `${this.url}/bucket/${id}`, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Creates a new Storage bucket - * - * @param id A unique identifier for the bucket you are creating. - * @param options.public The visibility of the bucket. Public buckets don't require an authorization token to download objects, but still require a valid token for all other operations. By default, buckets are private. - * @param options.fileSizeLimit specifies the max file size in bytes that can be uploaded to this bucket. - * The global file size limit takes precedence over this value. - * The default value is null, which doesn't set a per bucket file size limit. - * @param options.allowedMimeTypes specifies the allowed mime types that this bucket can accept during upload. - * The default value is null, which allows files with all mime types to be uploaded. - * Each mime type specified can be a wildcard, e.g. image/*, or a specific mime type, e.g. image/png. - * @returns newly created bucket id - */ - async createBucket( - id: string, - options: { - public: boolean; - fileSizeLimit?: number | string | null; - allowedMimeTypes?: string[] | null; - } = { - public: false, - }, - ): Promise< - | { - data: Pick; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await post( - this.fetch, - `${this.url}/bucket`, - { - id, - name: id, - public: options.public, - file_size_limit: options.fileSizeLimit, - allowed_mime_types: options.allowedMimeTypes, - }, - { headers: this.headers }, - ); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Updates a Storage bucket - * - * @param id A unique identifier for the bucket you are updating. - * @param options.public The visibility of the bucket. Public buckets don't require an authorization token to download objects, but still require a valid token for all other operations. - * @param options.fileSizeLimit specifies the max file size in bytes that can be uploaded to this bucket. - * The global file size limit takes precedence over this value. - * The default value is null, which doesn't set a per bucket file size limit. - * @param options.allowedMimeTypes specifies the allowed mime types that this bucket can accept during upload. - * The default value is null, which allows files with all mime types to be uploaded. - * Each mime type specified can be a wildcard, e.g. image/*, or a specific mime type, e.g. image/png. - */ - async updateBucket( - id: string, - options: { - public: boolean; - fileSizeLimit?: number | string | null; - allowedMimeTypes?: string[] | null; - }, - ): Promise< - | { - data: { message: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await put( - this.fetch, - `${this.url}/bucket/${id}`, - { - id, - name: id, - public: options.public, - file_size_limit: options.fileSizeLimit, - allowed_mime_types: options.allowedMimeTypes, - }, - { headers: this.headers }, - ); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Removes all objects inside a single bucket. - * - * @param id The unique identifier of the bucket you would like to empty. - */ - async emptyBucket(id: string): Promise< - | { - data: { message: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await post(this.fetch, `${this.url}/bucket/${id}/empty`, {}, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Deletes an existing bucket. A bucket can't be deleted with existing objects inside it. - * You must first `empty()` the bucket. - * - * @param id The unique identifier of the bucket you would like to delete. - */ - async deleteBucket(id: string): Promise< - | { - data: { message: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await remove(this.fetch, `${this.url}/bucket/${id}`, {}, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } -} diff --git a/packages/nativescript-supabase/supabase-storage/lib/StorageFileApi.ts b/packages/nativescript-supabase/supabase-storage/lib/StorageFileApi.ts deleted file mode 100644 index 704cc26..0000000 --- a/packages/nativescript-supabase/supabase-storage/lib/StorageFileApi.ts +++ /dev/null @@ -1,646 +0,0 @@ -import { isStorageError, StorageError } from '../lib/errors'; -import { Fetch, get, post, remove } from '../lib/fetch'; -import { resolveFetch } from '../lib/helpers'; -import { FileObject, FileOptions, SearchOptions, FetchParameters, TransformOptions } from '../lib/types'; -import { Http, HTTPFormData, HTTPFormDataEntry } from '@klippa/nativescript-http'; - -const DEFAULT_SEARCH_OPTIONS = { - limit: 100, - offset: 0, - sortBy: { - column: 'name', - order: 'asc', - }, -}; - -const DEFAULT_FILE_OPTIONS: FileOptions = { - cacheControl: '3600', - contentType: 'text/plain;charset=UTF-8', - upsert: false, -}; - -type FileBody = ArrayBuffer | ArrayBufferView | Blob | Buffer | File | FormData | NodeJS.ReadableStream | ReadableStream | URLSearchParams | string; - -export default class StorageFileApi { - protected url: string; - protected headers: { [key: string]: string }; - protected bucketId?: string; - protected fetch: Fetch; - - constructor(url: string, headers: { [key: string]: string } = {}, bucketId?: string, fetch?: Fetch) { - this.url = url; - this.headers = headers; - this.bucketId = bucketId; - this.fetch = resolveFetch(fetch); - } - - /** - * Uploads a file to an existing bucket or replaces an existing file at the specified path with a new one. - * - * @param method HTTP method. - * @param path The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - * @param fileBody The body of the file to be stored in the bucket. - */ - private async uploadOrUpdate( - method: 'POST' | 'PUT', - path: string, - fileBody: FileBody, - fileOptions?: FileOptions, - ): Promise< - | { - data: { path: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const body = new HTTPFormData(); - const options = { ...DEFAULT_FILE_OPTIONS, ...fileOptions }; - const headers: Record = { - ...this.headers, - ...(method === 'POST' && { 'x-upsert': String(options.upsert as boolean) }), - }; - - body.append('cacheControl', options.cacheControl); - - let fileData; - if (typeof fileBody === 'string') { - if (global.isAndroid) { - fileData = new HTTPFormDataEntry(new java.io.File(fileBody)); - } else if (global.isIOS) { - fileData = new HTTPFormDataEntry(NSData.dataWithContentsOfURL(NSURL.URLWithString(fileBody))); - } - } else if (fileBody instanceof File) { - fileData = new HTTPFormDataEntry(fileBody, fileBody.name, fileBody.type); - } else { - fileData = new HTTPFormDataEntry(fileBody); - headers['cache-control'] = `max-age=${options.cacheControl}`; - headers['content-type'] = options.contentType as string; - } - body.append('', fileData); - - const cleanPath = this._removeEmptyFolders(path); - const _path = this._getFinalPath(cleanPath); - const res = await Http.request({ - method: 'POST', - url: `${this.url}/object/${_path}`, - content: body, - headers: { ...this.headers }, - // ...(options?.duplex ? { duplex: options.duplex } : {}), - }); - - if (res.statusCode >= 200 && res.statusCode <= 299) { - // @ts-ignore - res.ok = true; - } - - // @ts-ignore - if (res.ok) { - return { - data: { path: cleanPath }, - error: null, - }; - } else { - const error = await res.content?.toJSON?.(); - return { data: null, error }; - } - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Uploads a file to an existing bucket. - * - * @param path The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - * @param fileBody The body of the file to be stored in the bucket. - */ - async upload( - path: string, - fileBody: FileBody, - fileOptions?: FileOptions, - ): Promise< - | { - data: { path: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - return this.uploadOrUpdate('POST', path, fileBody, fileOptions); - } - - /** - * Upload a file with a token generated from `createSignedUploadUrl`. - * @param path The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - * @param token The token generated from `createSignedUploadUrl` - * @param fileBody The body of the file to be stored in the bucket. - */ - async uploadToSignedUrl(path: string, token: string, fileBody: FileBody, fileOptions?: FileOptions) { - const cleanPath = this._removeEmptyFolders(path); - const _path = this._getFinalPath(cleanPath); - - const url = new URL(this.url + `/object/upload/sign/${_path}`); - url.searchParams.set('token', token); - - try { - const body = new HTTPFormData(); - const options = { upsert: DEFAULT_FILE_OPTIONS.upsert, ...fileOptions }; - const headers: Record = { - ...this.headers, - ...{ 'x-upsert': String(options.upsert as boolean) }, - }; - - body.append('cacheControl', options.cacheControl); - - let fileData; - if (typeof fileBody === 'string') { - if (global.isAndroid) { - fileData = new HTTPFormDataEntry(new java.io.File(fileBody)); - } else if (global.isIOS) { - fileData = new HTTPFormDataEntry(NSData.dataWithContentsOfURL(NSURL.URLWithString(fileBody))); - } - } else if (fileBody instanceof File) { - fileData = new HTTPFormDataEntry(fileBody, fileBody.name, fileBody.type); - } else { - fileData = new HTTPFormDataEntry(fileBody); - headers['cache-control'] = `max-age=${options.cacheControl}`; - headers['content-type'] = options.contentType as string; - } - body.append('', fileData); - - const res = await Http.request({ - method: 'PUT', - url: url.toString(), - content: body, - headers: { ...this.headers }, - }); - - if (res.statusCode >= 200 && res.statusCode <= 299) { - // @ts-ignore - res.ok = true; - } - - // @ts-ignore - if (res.ok) { - return { - data: { path: cleanPath }, - error: null, - }; - } else { - const error = await res.content?.toJSON?.(); - return { data: null, error }; - } - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Creates a signed upload URL. - * Signed upload URLs can be used to upload files to the bucket without further authentication. - * They are valid for one minute. - * @param path The file path, including the current file name. For example `folder/image.png`. - */ - async createSignedUploadUrl(path: string): Promise< - | { - data: { signedUrl: string; token: string; path: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const _path = this._getFinalPath(path); - - const data = await post(this.fetch, `${this.url}/object/upload/sign/${_path}`, {}, { headers: this.headers }); - - const url = new URL(this.url + data.url); - - const token = url.searchParams.get('token'); - - if (!token) { - throw new StorageError('No token returned by API'); - } - - return { data: { signedUrl: url.toString(), path, token }, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Replaces an existing file at the specified path with a new one. - * - * @param path The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to update. - * @param fileBody The body of the file to be stored in the bucket. - */ - async update( - path: string, - fileBody: ArrayBuffer | ArrayBufferView | Blob | Buffer | File | FormData | NodeJS.ReadableStream | ReadableStream | URLSearchParams | string, - fileOptions?: FileOptions, - ): Promise< - | { - data: { path: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - return this.uploadOrUpdate('PUT', path, fileBody, fileOptions); - } - - /** - * Moves an existing file to a new path in the same bucket. - * - * @param fromPath The original file path, including the current file name. For example `folder/image.png`. - * @param toPath The new file path, including the new file name. For example `folder/image-new.png`. - */ - async move( - fromPath: string, - toPath: string, - ): Promise< - | { - data: { message: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await post(this.fetch, `${this.url}/object/move`, { bucketId: this.bucketId, sourceKey: fromPath, destinationKey: toPath }, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Copies an existing file to a new path in the same bucket. - * - * @param fromPath The original file path, including the current file name. For example `folder/image.png`. - * @param toPath The new file path, including the new file name. For example `folder/image-copy.png`. - */ - async copy( - fromPath: string, - toPath: string, - ): Promise< - | { - data: { path: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await post(this.fetch, `${this.url}/object/copy`, { bucketId: this.bucketId, sourceKey: fromPath, destinationKey: toPath }, { headers: this.headers }); - return { data: { path: data.Key }, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Creates a signed URL. Use a signed URL to share a file for a fixed amount of time. - * - * @param path The file path, including the current file name. For example `folder/image.png`. - * @param expiresIn The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. - * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. - * @param options.transform Transform the asset before serving it to the client. - */ - async createSignedUrl( - path: string, - expiresIn: number, - options?: { download?: string | boolean; transform?: TransformOptions }, - ): Promise< - | { - data: { signedUrl: string }; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const _path = this._getFinalPath(path); - - let data = await post(this.fetch, `${this.url}/object/sign/${_path}`, { expiresIn, ...(options?.transform ? { transform: options.transform } : {}) }, { headers: this.headers }); - const downloadQueryParam = options?.download ? `&download=${options.download === true ? '' : options.download}` : ''; - const signedUrl = encodeURI(`${this.url}${data.signedURL}${downloadQueryParam}`); - data = { signedUrl }; - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Creates multiple signed URLs. Use a signed URL to share a file for a fixed amount of time. - * - * @param paths The file paths to be downloaded, including the current file names. For example `['folder/image.png', 'folder2/image2.png']`. - * @param expiresIn The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute. - * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. - */ - async createSignedUrls( - paths: string[], - expiresIn: number, - options?: { download: string | boolean }, - ): Promise< - | { - data: { error: string | null; path: string | null; signedUrl: string }[]; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await post(this.fetch, `${this.url}/object/sign/${this.bucketId}`, { expiresIn, paths }, { headers: this.headers }); - - const downloadQueryParam = options?.download ? `&download=${options.download === true ? '' : options.download}` : ''; - return { - data: data.map((datum: { signedURL: string }) => ({ - ...datum, - signedUrl: datum.signedURL ? encodeURI(`${this.url}${datum.signedURL}${downloadQueryParam}`) : null, - })), - error: null, - }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Downloads a file from a private bucket. For public buckets, make a request to the URL returned from `getPublicUrl` instead. - * - * @param path The full path and file name of the file to be downloaded. For example `folder/image.png`. - * @param options.transform Transform the asset before serving it to the client. - */ - async download( - path: string, - options?: { transform?: TransformOptions }, - ): Promise< - | { - data: Blob; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - const wantsTransformation = typeof options?.transform !== 'undefined'; - const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object'; - const transformationQuery = this.transformOptsToQueryString(options?.transform || {}); - const queryString = transformationQuery ? `?${transformationQuery}` : ''; - - try { - const _path = this._getFinalPath(path); - const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, { - headers: this.headers, - noResolveJson: true, - }); - const data = await res.blob(); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. - * This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. - * - * @param path The path and name of the file to generate the public URL for. For example `folder/image.png`. - * @param options.download Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. - * @param options.transform Transform the asset before serving it to the client. - */ - getPublicUrl(path: string, options?: { download?: string | boolean; transform?: TransformOptions }): { data: { publicUrl: string } } { - const _path = this._getFinalPath(path); - const _queryString = []; - - const downloadQueryParam = options?.download ? `download=${options.download === true ? '' : options.download}` : ''; - - if (downloadQueryParam !== '') { - _queryString.push(downloadQueryParam); - } - - const wantsTransformation = typeof options?.transform !== 'undefined'; - const renderPath = wantsTransformation ? 'render/image' : 'object'; - const transformationQuery = this.transformOptsToQueryString(options?.transform || {}); - - if (transformationQuery !== '') { - _queryString.push(transformationQuery); - } - - let queryString = _queryString.join('&'); - if (queryString !== '') { - queryString = `?${queryString}`; - } - - return { - data: { publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}${queryString}`) }, - }; - } - - /** - * Deletes files within the same bucket - * - * @param paths An array of files to delete, including the path and file name. For example [`'folder/image.png'`]. - */ - async remove(paths: string[]): Promise< - | { - data: FileObject[]; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const data = await remove(this.fetch, `${this.url}/object/${this.bucketId}`, { prefixes: paths }, { headers: this.headers }); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - /** - * Get file metadata - * @param id the file id to retrieve metadata - */ - // async getMetadata( - // id: string - // ): Promise< - // | { - // data: Metadata - // error: null - // } - // | { - // data: null - // error: StorageError - // } - // > { - // try { - // const data = await get(this.fetch, `${this.url}/metadata/${id}`, { headers: this.headers }) - // return { data, error: null } - // } catch (error) { - // if (isStorageError(error)) { - // return { data: null, error } - // } - - // throw error - // } - // } - - /** - * Update file metadata - * @param id the file id to update metadata - * @param meta the new file metadata - */ - // async updateMetadata( - // id: string, - // meta: Metadata - // ): Promise< - // | { - // data: Metadata - // error: null - // } - // | { - // data: null - // error: StorageError - // } - // > { - // try { - // const data = await post( - // this.fetch, - // `${this.url}/metadata/${id}`, - // { ...meta }, - // { headers: this.headers } - // ) - // return { data, error: null } - // } catch (error) { - // if (isStorageError(error)) { - // return { data: null, error } - // } - - // throw error - // } - // } - - /** - * Lists all the files within a bucket. - * @param path The folder path. - */ - async list( - path?: string, - options?: SearchOptions, - parameters?: FetchParameters, - ): Promise< - | { - data: FileObject[]; - error: null; - } - | { - data: null; - error: StorageError; - } - > { - try { - const body = { ...DEFAULT_SEARCH_OPTIONS, ...options, prefix: path || '' }; - const data = await post(this.fetch, `${this.url}/object/list/${this.bucketId}`, body, { headers: this.headers }, parameters); - return { data, error: null }; - } catch (error) { - if (isStorageError(error)) { - return { data: null, error }; - } - - throw error; - } - } - - private _getFinalPath(path: string) { - return `${this.bucketId}/${path}`; - } - - private _removeEmptyFolders(path: string) { - return path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/'); - } - - private transformOptsToQueryString(transform: TransformOptions) { - const params = []; - if (transform.width) { - params.push(`width=${transform.width}`); - } - - if (transform.height) { - params.push(`height=${transform.height}`); - } - - if (transform.resize) { - params.push(`resize=${transform.resize}`); - } - - if (transform.format) { - params.push(`format=${transform.format}`); - } - - if (transform.quality) { - params.push(`quality=${transform.quality}`); - } - - return params.join('&'); - } -} diff --git a/packages/nativescript-supabase/supabase-storage/lib/common/BaseApiClient.ts b/packages/nativescript-supabase/supabase-storage/lib/common/BaseApiClient.ts new file mode 100644 index 0000000..f81cf06 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/lib/common/BaseApiClient.ts @@ -0,0 +1,44 @@ +import { ErrorNamespace, isStorageError, StorageError } from './errors'; +import { Fetch } from './fetch'; +import { normalizeHeaders, setHeader as setHeaderUtil } from './headers'; +import { resolveFetch } from './helpers'; + +export default abstract class BaseApiClient { + protected url: string; + protected headers: { [key: string]: string }; + protected fetch: Fetch; + protected shouldThrowOnError = false; + protected namespace: ErrorNamespace; + + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch, namespace: ErrorNamespace = 'storage') { + this.url = url; + this.headers = normalizeHeaders(headers); + this.fetch = resolveFetch(fetch); + this.namespace = namespace; + } + + public throwOnError(): this { + this.shouldThrowOnError = true; + return this; + } + + public setHeader(name: string, value: string): this { + this.headers = setHeaderUtil(this.headers, name, value); + return this; + } + + protected async handleOperation(operation: () => Promise): Promise<{ data: T; error: null } | { data: null; error: TError }> { + try { + const data = await operation(); + return { data, error: null }; + } catch (error) { + if (this.shouldThrowOnError) { + throw error; + } + if (isStorageError(error)) { + return { data: null, error: error as TError }; + } + throw error; + } + } +} diff --git a/packages/nativescript-supabase/supabase-storage/lib/common/errors.ts b/packages/nativescript-supabase/supabase-storage/lib/common/errors.ts new file mode 100644 index 0000000..58217df --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/lib/common/errors.ts @@ -0,0 +1,101 @@ +export type ErrorNamespace = 'storage' | 'vectors'; + +export class StorageError extends Error { + protected __isStorageError = true; + protected namespace: ErrorNamespace; + status: number | undefined; + statusCode: string | undefined; + + constructor(message: string, namespace: ErrorNamespace = 'storage', status?: number, statusCode?: string) { + super(message); + this.namespace = namespace; + this.name = namespace === 'vectors' ? 'StorageVectorsError' : 'StorageError'; + this.status = status; + this.statusCode = statusCode; + } + + toJSON(): { + name: string; + message: string; + status: number | undefined; + statusCode: string | undefined; + } { + return { + name: this.name, + message: this.message, + status: this.status, + statusCode: this.statusCode, + }; + } +} + +export function isStorageError(error: unknown): error is StorageError { + return typeof error === 'object' && error !== null && '__isStorageError' in error; +} + +export class StorageApiError extends StorageError { + override status: number; + override statusCode: string; + + constructor(message: string, status: number, statusCode: string, namespace: ErrorNamespace = 'storage') { + super(message, namespace, status, statusCode); + this.name = namespace === 'vectors' ? 'StorageVectorsApiError' : 'StorageApiError'; + this.status = status; + this.statusCode = statusCode; + } + + toJSON(): { + name: string; + message: string; + status: number | undefined; + statusCode: string | undefined; + } { + return { + ...super.toJSON(), + }; + } +} + +export class StorageUnknownError extends StorageError { + originalError: unknown; + + constructor(message: string, originalError: unknown, namespace: ErrorNamespace = 'storage') { + super(message, namespace); + this.name = namespace === 'vectors' ? 'StorageVectorsUnknownError' : 'StorageUnknownError'; + this.originalError = originalError; + } +} + +/** @deprecated Use StorageError with namespace='vectors' instead */ +export class StorageVectorsError extends StorageError { + constructor(message: string) { + super(message, 'vectors'); + } +} + +export function isStorageVectorsError(error: unknown): error is StorageVectorsError { + return isStorageError(error) && (error as StorageError)['namespace'] === 'vectors'; +} + +/** @deprecated Use StorageApiError with namespace='vectors' instead */ +export class StorageVectorsApiError extends StorageApiError { + constructor(message: string, status: number, statusCode: string) { + super(message, status, statusCode, 'vectors'); + } +} + +/** @deprecated Use StorageUnknownError with namespace='vectors' instead */ +export class StorageVectorsUnknownError extends StorageUnknownError { + constructor(message: string, originalError: unknown) { + super(message, originalError, 'vectors'); + } +} + +export enum StorageVectorsErrorCode { + InternalError = 'InternalError', + S3VectorConflictException = 'S3VectorConflictException', + S3VectorNotFoundException = 'S3VectorNotFoundException', + S3VectorBucketNotEmpty = 'S3VectorBucketNotEmpty', + S3VectorMaxBucketsExceeded = 'S3VectorMaxBucketsExceeded', + S3VectorMaxIndexesExceeded = 'S3VectorMaxIndexesExceeded', +} diff --git a/packages/nativescript-supabase/supabase-storage/lib/common/fetch.ts b/packages/nativescript-supabase/supabase-storage/lib/common/fetch.ts new file mode 100644 index 0000000..35944d2 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/lib/common/fetch.ts @@ -0,0 +1,154 @@ +import { StorageApiError, StorageUnknownError, ErrorNamespace } from './errors'; +import { setHeader } from './headers'; +import { isPlainObject } from './helpers'; +import { FetchParameters } from '../types'; + +export type Fetch = typeof fetch; + +export interface FetchOptions { + headers?: { + [key: string]: string; + }; + duplex?: string; + noResolveJson?: boolean; +} + +export type RequestMethodType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD'; + +const _getErrorMessage = (err: unknown): string => { + if (typeof err === 'object' && err !== null) { + const e = err as Record; + if (typeof e.msg === 'string') return e.msg; + if (typeof e.message === 'string') return e.message; + if (typeof e.error_description === 'string') return e.error_description; + if (typeof e.error === 'string') return e.error; + if (typeof e.error === 'object' && e.error !== null) { + const nested = e.error as Record; + if (typeof nested.message === 'string') return nested.message; + } + } + return JSON.stringify(err); +}; + +const handleError = async (error: unknown, reject: (reason: StorageApiError | StorageUnknownError) => void, options: FetchOptions | undefined, namespace: ErrorNamespace) => { + const isResponseLike = error !== null && typeof error === 'object' && 'json' in error && typeof (error as Record).json === 'function'; + + if (isResponseLike) { + const responseError = error as Response; + let status = parseInt(String(responseError.status), 10); + if (!Number.isFinite(status)) { + status = 500; + } + + responseError + .json() + .then((err: { statusCode?: string; code?: string; error?: string; message?: string } | null) => { + const statusCode = err?.statusCode || err?.code || status + ''; + reject(new StorageApiError(_getErrorMessage(err), status, statusCode, namespace)); + }) + .catch(() => { + const statusCode = status + ''; + const message = responseError.statusText || `HTTP ${status} error`; + reject(new StorageApiError(message, status, statusCode, namespace)); + }); + } else { + reject(new StorageUnknownError(_getErrorMessage(error), error, namespace)); + } +}; + +const _getRequestParams = (method: RequestMethodType, options?: FetchOptions, parameters?: FetchParameters, body?: object) => { + const params: { [k: string]: any } = { method, headers: options?.headers || {} }; + + if (method === 'GET' || method === 'HEAD' || !body) { + return { ...params, ...parameters }; + } + + if (isPlainObject(body)) { + const headers = options?.headers || {}; + let contentType: string | undefined; + + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'content-type') { + contentType = value; + } + } + + params.headers = setHeader(headers, 'Content-Type', contentType ?? 'application/json'); + params.body = JSON.stringify(body); + } else { + params.body = body; + } + + if (options?.duplex) { + params.duplex = options.duplex; + } + + return { ...params, ...parameters }; +}; + +async function _handleRequest(fetcher: Fetch, method: RequestMethodType, url: string, options: FetchOptions | undefined, parameters: FetchParameters | undefined, body: object | undefined, namespace: ErrorNamespace): Promise { + return new Promise((resolve, reject) => { + fetcher(url, _getRequestParams(method, options, parameters, body)) + .then((result) => { + if (!result.ok) throw result; + if (options?.noResolveJson) return result; + + if (namespace === 'vectors') { + const contentType = result.headers.get('content-type'); + const contentLength = result.headers.get('content-length'); + + if (contentLength === '0' || result.status === 204) { + return {}; + } + + if (!contentType || !contentType.includes('application/json')) { + return {}; + } + } + + return result.json(); + }) + .then((data) => resolve(data)) + .catch((error) => handleError(error, reject, options, namespace)); + }); +} + +export function createFetchApi(namespace: ErrorNamespace = 'storage') { + return { + get: async (fetcher: Fetch, url: string, options?: FetchOptions, parameters?: FetchParameters): Promise => { + return _handleRequest(fetcher, 'GET', url, options, parameters, undefined, namespace); + }, + + post: async (fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise => { + return _handleRequest(fetcher, 'POST', url, options, parameters, body, namespace); + }, + + put: async (fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise => { + return _handleRequest(fetcher, 'PUT', url, options, parameters, body, namespace); + }, + + head: async (fetcher: Fetch, url: string, options?: FetchOptions, parameters?: FetchParameters): Promise => { + return _handleRequest( + fetcher, + 'HEAD', + url, + { + ...options, + noResolveJson: true, + }, + parameters, + undefined, + namespace, + ); + }, + + remove: async (fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise => { + return _handleRequest(fetcher, 'DELETE', url, options, parameters, body, namespace); + }, + }; +} + +const defaultApi = createFetchApi('storage'); +export const { get, post, put, head, remove } = defaultApi; + +export const vectorsApi = createFetchApi('vectors'); diff --git a/packages/nativescript-supabase/supabase-storage/lib/common/headers.ts b/packages/nativescript-supabase/supabase-storage/lib/common/headers.ts new file mode 100644 index 0000000..a8ae2f6 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/lib/common/headers.ts @@ -0,0 +1,21 @@ +export function setHeader(headers: Record, name: string, value: string): Record { + const result = { ...headers }; + const nameLower = name.toLowerCase(); + + for (const key of Object.keys(result)) { + if (key.toLowerCase() === nameLower) { + delete result[key]; + } + } + + result[nameLower] = value; + return result; +} + +export function normalizeHeaders(headers: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + result[key.toLowerCase()] = value; + } + return result; +} diff --git a/packages/nativescript-supabase/supabase-storage/lib/common/helpers.ts b/packages/nativescript-supabase/supabase-storage/lib/common/helpers.ts new file mode 100644 index 0000000..472c947 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/lib/common/helpers.ts @@ -0,0 +1,70 @@ +type Fetch = typeof fetch; + +export const resolveFetch = (customFetch?: Fetch): Fetch => { + if (customFetch) { + // @ts-ignore + return (...args) => customFetch(...args); + } + // @ts-ignore + return (...args) => fetch(...args); +}; + +export const resolveResponse = (): typeof Response => { + return Response; +}; + +export const isPlainObject = (value: object): boolean => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value); +}; + +export const recursiveToCamel = (item: Record): unknown => { + if (Array.isArray(item)) { + return item.map((el) => recursiveToCamel(el)); + } else if (typeof item === 'function' || item !== Object(item)) { + return item; + } + + const result: Record = {}; + Object.entries(item).forEach(([key, value]) => { + const newKey = key.replace(/([-_][a-z])/gi, (c) => c.toUpperCase().replace(/[-_]/g, '')); + result[newKey] = recursiveToCamel(value); + }); + + return result; +}; + +export const isValidBucketName = (bucketName: string): boolean => { + if (!bucketName || typeof bucketName !== 'string') { + return false; + } + + if (bucketName.length === 0 || bucketName.length > 100) { + return false; + } + + if (bucketName.trim() !== bucketName) { + return false; + } + + if (bucketName.includes('/') || bucketName.includes('\\')) { + return false; + } + + const bucketNameRegex = /^[\w!.\*'() &$@=;:+,?-]+$/; + return bucketNameRegex.test(bucketName); +}; + +export const normalizeToFloat32 = (values: number[]): number[] => { + return Array.from(new Float32Array(values)); +}; + +export const validateVectorDimension = (vector: { float32: number[] }, expectedDimension?: number): void => { + if (expectedDimension !== undefined && vector.float32.length !== expectedDimension) { + throw new Error(`Vector dimension mismatch: expected ${expectedDimension}, got ${vector.float32.length}`); + } +}; diff --git a/packages/nativescript-supabase/supabase-storage/lib/errors.ts b/packages/nativescript-supabase/supabase-storage/lib/errors.ts deleted file mode 100644 index bfef17f..0000000 --- a/packages/nativescript-supabase/supabase-storage/lib/errors.ts +++ /dev/null @@ -1,40 +0,0 @@ -export class StorageError extends Error { - protected __isStorageError = true; - - constructor(message: string) { - super(message); - this.name = 'StorageError'; - } -} - -export function isStorageError(error: unknown): error is StorageError { - return typeof error === 'object' && error !== null && '__isStorageError' in error; -} - -export class StorageApiError extends StorageError { - status: number; - - constructor(message: string, status: number) { - super(message); - this.name = 'StorageApiError'; - this.status = status; - } - - toJSON() { - return { - name: this.name, - message: this.message, - status: this.status, - }; - } -} - -export class StorageUnknownError extends StorageError { - originalError: unknown; - - constructor(message: string, originalError: unknown) { - super(message); - this.name = 'StorageUnknownError'; - this.originalError = originalError; - } -} diff --git a/packages/nativescript-supabase/supabase-storage/lib/fetch.ts b/packages/nativescript-supabase/supabase-storage/lib/fetch.ts deleted file mode 100644 index 29e5427..0000000 --- a/packages/nativescript-supabase/supabase-storage/lib/fetch.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { StorageApiError, StorageUnknownError } from './errors'; -import { resolveResponse } from './helpers'; -import { FetchParameters } from './types'; - -export type Fetch = typeof fetch; - -export interface FetchOptions { - headers?: { - [key: string]: string; - }; - noResolveJson?: boolean; -} - -export type RequestMethodType = 'GET' | 'POST' | 'PUT' | 'DELETE'; - -const _getErrorMessage = (err: any): string => err.msg || err.message || err.error_description || err.error || JSON.stringify(err); - -const handleError = async (error: unknown, reject: (reason?: any) => void) => { - const Res = await resolveResponse(); - - if (error instanceof Res) { - error - .json() - .then((err) => { - reject(new StorageApiError(_getErrorMessage(err), error.status || 500)); - }) - .catch((err) => { - reject(new StorageUnknownError(_getErrorMessage(err), err)); - }); - } else { - reject(new StorageUnknownError(_getErrorMessage(error), error)); - } -}; - -const _getRequestParams = (method: RequestMethodType, options?: FetchOptions, parameters?: FetchParameters, body?: object) => { - const params: { [k: string]: any } = { method, headers: options?.headers || {} }; - - if (method === 'GET') { - return params; - } - - params.headers = { 'Content-Type': 'application/json', ...options?.headers }; - params.body = JSON.stringify(body); - return { ...params, ...parameters }; -}; - -async function _handleRequest(fetcher: Fetch, method: RequestMethodType, url: string, options?: FetchOptions, parameters?: FetchParameters, body?: object): Promise { - return new Promise((resolve, reject) => { - fetcher(url, _getRequestParams(method, options, parameters, body)) - .then((result) => { - if (!result.ok) throw result; - if (options?.noResolveJson) return result; - return result.json(); - }) - .then((data) => resolve(data)) - .catch((error) => handleError(error, reject)); - }); -} - -export async function get(fetcher: Fetch, url: string, options?: FetchOptions, parameters?: FetchParameters): Promise { - return _handleRequest(fetcher, 'GET', url, options, parameters); -} - -export async function post(fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise { - return _handleRequest(fetcher, 'POST', url, options, parameters, body); -} - -export async function put(fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise { - return _handleRequest(fetcher, 'PUT', url, options, parameters, body); -} - -export async function remove(fetcher: Fetch, url: string, body: object, options?: FetchOptions, parameters?: FetchParameters): Promise { - return _handleRequest(fetcher, 'DELETE', url, options, parameters, body); -} diff --git a/packages/nativescript-supabase/supabase-storage/lib/helpers.ts b/packages/nativescript-supabase/supabase-storage/lib/helpers.ts deleted file mode 100644 index c12b8ee..0000000 --- a/packages/nativescript-supabase/supabase-storage/lib/helpers.ts +++ /dev/null @@ -1,20 +0,0 @@ -type Fetch = typeof fetch; - -export const resolveFetch = (customFetch?: Fetch): Fetch => { - let _fetch: Fetch; - if (customFetch) { - _fetch = customFetch; - } else { - _fetch = fetch; - } - // @ts-ignore - return (...args) => _fetch(...args); -}; - -export const resolveResponse = async () => { - // if (typeof Response === 'undefined') { - // return (await import('cross-fetch')).Response; - // } - - return Response; -}; diff --git a/packages/nativescript-supabase/supabase-storage/lib/index.ts b/packages/nativescript-supabase/supabase-storage/lib/index.ts index c22aabb..2eb1e32 100644 --- a/packages/nativescript-supabase/supabase-storage/lib/index.ts +++ b/packages/nativescript-supabase/supabase-storage/lib/index.ts @@ -1,4 +1,4 @@ -export * from './StorageBucketApi'; -export * from './StorageFileApi'; +export * from '../packages/StorageBucketApi'; +export * from '../packages/StorageFileApi'; export * from './types'; export * from './constants'; diff --git a/packages/nativescript-supabase/supabase-storage/lib/types.ts b/packages/nativescript-supabase/supabase-storage/lib/types.ts index 985a829..034e7fd 100644 --- a/packages/nativescript-supabase/supabase-storage/lib/types.ts +++ b/packages/nativescript-supabase/supabase-storage/lib/types.ts @@ -1,5 +1,10 @@ +import { StorageError } from './common/errors'; + +export type BucketType = 'STANDARD' | 'ANALYTICS' | (string & {}); + export interface Bucket { id: string; + type?: BucketType; name: string; owner: string; file_size_limit?: number; @@ -9,16 +14,63 @@ export interface Bucket { public: boolean; } +export interface ListBucketOptions { + limit?: number; + offset?: number; + sortColumn?: 'id' | 'name' | 'created_at' | 'updated_at'; + sortOrder?: 'asc' | 'desc'; + search?: string; +} + +export interface AnalyticBucket { + name: string; + type: 'ANALYTICS'; + format: string; + created_at: string; + updated_at: string; +} + +export interface FileMetadata { + eTag: string; + size: number; + mimetype: string; + cacheControl: string; + lastModified: string; + contentLength: number; + httpStatusCode: number; + [key: string]: any; +} + export interface FileObject { name: string; - bucket_id: string; - owner: string; + id: string | null; + updated_at: string | null; + created_at: string | null; + /** @deprecated */ + last_accessed_at: string | null; + metadata: FileMetadata | null; + /** @deprecated */ + bucket_id?: string; + /** @deprecated */ + owner?: string; + /** @deprecated */ + buckets?: Bucket; +} + +export interface FileObjectV2 { id: string; - updated_at: string; + version: string; + name: string; + bucket_id: string; created_at: string; - last_accessed_at: string; - metadata: Record; - buckets: Bucket; + size?: number; + cache_control?: string; + content_type?: string; + etag?: string; + last_modified?: string; + metadata?: FileMetadata; + /** @deprecated The API returns last_modified instead. */ + updated_at?: string; } export interface SortBy { @@ -27,85 +79,240 @@ export interface SortBy { } export interface FileOptions { - /** - * The number of seconds the asset is cached in the browser and in the Supabase CDN. This is set in the `Cache-Control: max-age=` header. Defaults to 3600 seconds. - */ cacheControl?: string; - /** - * the `Content-Type` header value. Should be specified if using a `fileBody` that is neither `Blob` nor `File` nor `FormData`, otherwise will default to `text/plain;charset=UTF-8`. - */ contentType?: string; - /** - * When upsert is set to true, the file is overwritten if it exists. When set to false, an error is thrown if the object already exists. Defaults to false. - */ upsert?: boolean; - /** - * The duplex option is a string parameter that enables or disables duplex streaming, allowing for both reading and writing data in the same stream. It can be passed as an option to the fetch() method. - */ duplex?: string; + metadata?: Record; + headers?: Record; +} + +export interface DestinationOptions { + destinationBucket?: string; } export interface SearchOptions { - /** - * The number of files you want to be returned. - */ + /** @default 100 */ limit?: number; - - /** - * The starting position. - */ offset?: number; - - /** - * The column to sort by. Can be any column inside a FileObject. - */ sortBy?: SortBy; - - /** - * The search string to filter files by. - */ search?: string; } +export interface SortByV2 { + column: 'name' | 'updated_at' | 'created_at'; + order?: 'asc' | 'desc'; +} + +export interface SearchV2Options { + /** @default 1000 */ + limit?: number; + prefix?: string; + cursor?: string; + /** @default false */ + with_delimiter?: boolean; + /** @default 'name asc' */ + sortBy?: SortByV2; +} + +export interface SearchV2Object { + name: string; + key?: string; + id: string; + updated_at: string; + created_at: string; + metadata: FileMetadata | null; + /** @deprecated */ + last_accessed_at: string; +} + +export interface SearchV2Folder { + name: string; + key?: string; +} + +export interface SearchV2Result { + hasNext: boolean; + folders: SearchV2Folder[]; + objects: SearchV2Object[]; + nextCursor?: string; +} + export interface FetchParameters { - /** - * Pass in an AbortController's signal to cancel the request. - */ signal?: AbortSignal; + cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache' | 'only-if-cached'; } -// TODO: need to check for metadata props. The api swagger doesnt have. export interface Metadata { name: string; } export interface TransformOptions { - /** - * The width of the image in pixels. - */ width?: number; - /** - * The height of the image in pixels. - */ height?: number; - /** - * The resize mode can be cover, contain or fill. Defaults to cover. - * Cover resizes the image to maintain it's aspect ratio while filling the entire width and height. - * Contain resizes the image to maintain it's aspect ratio while fitting the entire image within the width and height. - * Fill resizes the image to fill the entire width and height. If the object's aspect ratio does not match the width and height, the image will be stretched to fit. - */ resize?: 'cover' | 'contain' | 'fill'; - /** - * Set the quality of the returned image. - * A number from 20 to 100, with 100 being the highest quality. - * Defaults to 80 - */ quality?: number; - /** - * Specify the format of the image requested. - * - * When using 'origin' we force the format to be the same as the original image. - * When this option is not passed in, images are optimized to modern image formats like Webp. - */ format?: 'origin'; } + +type CamelCase = S extends `${infer P1}_${infer P2}${infer P3}` ? `${Lowercase}${Uppercase}${CamelCase}` : S; + +export type Camelize = { + [K in keyof T as CamelCase>]: T[K]; +}; + +export type DownloadResult = + | { + data: T; + error: null; + } + | { + data: null; + error: StorageError; + }; + +// Vector Storage Types + +export interface EncryptionConfiguration { + kmsKeyArn?: string; + sseType?: string; +} + +export interface VectorBucket { + vectorBucketName: string; + creationTime?: number; + encryptionConfiguration?: EncryptionConfiguration; +} + +export interface MetadataConfiguration { + nonFilterableMetadataKeys?: string[]; +} + +export type VectorDataType = 'float32' | (string & {}); + +export type DistanceMetric = 'cosine' | 'euclidean' | 'dotproduct' | (string & {}); + +export interface VectorIndex { + indexName: string; + vectorBucketName: string; + dataType: VectorDataType; + dimension: number; + distanceMetric: DistanceMetric; + metadataConfiguration?: MetadataConfiguration; + creationTime?: number; +} + +export interface VectorData { + float32: number[]; +} + +export type VectorMetadata = Record; + +export interface VectorObject { + key: string; + data: VectorData; + metadata?: VectorMetadata; +} + +export interface VectorMatch { + key: string; + data?: VectorData; + metadata?: VectorMetadata; + distance?: number; +} + +export interface ListVectorBucketsOptions { + prefix?: string; + maxResults?: number; + nextToken?: string; +} + +export interface ListVectorBucketsResponse { + vectorBuckets: { vectorBucketName: string }[]; + nextToken?: string; +} + +export interface ListIndexesOptions { + vectorBucketName: string; + prefix?: string; + maxResults?: number; + nextToken?: string; +} + +export interface ListIndexesResponse { + indexes: { indexName: string }[]; + nextToken?: string; +} + +export interface GetVectorsOptions { + vectorBucketName: string; + indexName: string; + keys: string[]; + returnData?: boolean; + returnMetadata?: boolean; +} + +export interface GetVectorsResponse { + vectors: VectorMatch[]; +} + +export interface PutVectorsOptions { + vectorBucketName: string; + indexName: string; + vectors: VectorObject[]; +} + +export interface DeleteVectorsOptions { + vectorBucketName: string; + indexName: string; + keys: string[]; +} + +export interface ListVectorsOptions { + vectorBucketName: string; + indexName: string; + maxResults?: number; + nextToken?: string; + returnData?: boolean; + returnMetadata?: boolean; + segmentCount?: number; + segmentIndex?: number; +} + +export interface ListVectorsResponse { + vectors: VectorMatch[]; + nextToken?: string; +} + +export type VectorFilter = Record; + +export interface QueryVectorsOptions { + vectorBucketName: string; + indexName: string; + queryVector: VectorData; + topK?: number; + filter?: VectorFilter; + returnDistance?: boolean; + returnMetadata?: boolean; +} + +export interface QueryVectorsResponse { + vectors: VectorMatch[]; + distanceMetric?: DistanceMetric; +} + +export interface VectorFetchParameters { + signal?: AbortSignal; +} + +export interface SuccessResponse { + data: T; + error: null; +} + +export interface ErrorResponse { + data: null; + error: StorageError; +} + +export type ApiResponse = SuccessResponse | ErrorResponse; diff --git a/packages/nativescript-supabase/supabase-storage/package.json b/packages/nativescript-supabase/supabase-storage/package.json index d61fc38..28532ee 100644 --- a/packages/nativescript-supabase/supabase-storage/package.json +++ b/packages/nativescript-supabase/supabase-storage/package.json @@ -1,7 +1,7 @@ { - "name": "@triniwiz/nativescript-supabase-storage", - "version": "2.0.0-rc.7", - "description": "A Nativescript client library to interact with Supabase Storage", + "name": "@edusperoni/nativescript-supabase-storage", + "version": "2.106.0", + "description": "Isomorphic storage client for Supabase, with NativeScript-specific overrides.", "main": "index", "typings": "index.d.ts", "nativescript": { @@ -12,7 +12,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/triniwiz/nativeScript-plugins.git" + "url": "https://github.com/edusperoni/nativescript-plugins.git" }, "keywords": [ "NativeScript", @@ -22,17 +22,17 @@ "Android" ], "author": { - "name": "Osei Fortune", - "email": "fortune.osei@yahoo.com" + "name": "Eduardo Speroni", + "email": "eduardo.speroni@valor-software.com" }, "bugs": { - "url": "https://github.com/triniwiz/nativeScript-plugins/issues" + "url": "https://github.com/edusperoni/nativescript-plugins/issues" }, "license": "Apache-2.0", - "homepage": "https://github.com/triniwiz/nativeScript-plugins", + "homepage": "https://github.com/edusperoni/nativescript-plugins", "readmeFilename": "README.md", - "bootstrapper": "@nativescript/plugin-seed", "dependencies": { - "@klippa/nativescript-http": "3.0.4" + "@klippa/nativescript-http": "3.0.4", + "iceberg-js": "^0.8.1" } } diff --git a/packages/nativescript-supabase/supabase-storage/packages/BlobDownloadBuilder.ts b/packages/nativescript-supabase/supabase-storage/packages/BlobDownloadBuilder.ts new file mode 100644 index 0000000..0938861 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/BlobDownloadBuilder.ts @@ -0,0 +1,57 @@ +import { isStorageError } from '../lib/common/errors'; +import { DownloadResult } from '../lib/types'; +import StreamDownloadBuilder from './StreamDownloadBuilder'; + +export default class BlobDownloadBuilder implements Promise> { + readonly [Symbol.toStringTag]: string = 'BlobDownloadBuilder'; + private promise: Promise> | null = null; + + constructor( + private downloadFn: () => Promise, + private shouldThrowOnError: boolean, + ) {} + + asStream(): StreamDownloadBuilder { + return new StreamDownloadBuilder(this.downloadFn, this.shouldThrowOnError); + } + + then, TResult2 = never>(onfulfilled?: ((value: DownloadResult) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null): Promise { + return this.getPromise().then(onfulfilled, onrejected); + } + + catch(onrejected?: ((reason: any) => TResult | PromiseLike) | null): Promise | TResult> { + return this.getPromise().catch(onrejected); + } + + finally(onfinally?: (() => void) | null): Promise> { + return this.getPromise().finally(onfinally); + } + + private getPromise(): Promise> { + if (!this.promise) { + this.promise = this.execute(); + } + return this.promise; + } + + private async execute(): Promise> { + try { + const result = await this.downloadFn(); + + return { + data: await result.blob(), + error: null, + }; + } catch (error) { + if (this.shouldThrowOnError) { + throw error; + } + + if (isStorageError(error)) { + return { data: null, error }; + } + + throw error; + } + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/StorageAnalyticsClient.ts b/packages/nativescript-supabase/supabase-storage/packages/StorageAnalyticsClient.ts new file mode 100644 index 0000000..026b01f --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/StorageAnalyticsClient.ts @@ -0,0 +1,117 @@ +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; +}; + +export default class StorageAnalyticsClient extends BaseApiClient { + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { + const finalUrl = url.replace(/\/$/, ''); + const finalHeaders = { ...DEFAULT_HEADERS, ...headers }; + super(finalUrl, finalHeaders, fetch, 'storage'); + } + + 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 }); + }); + } + + 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 () => { + 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 }); + }); + } + + 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 }); + }); + } + + from(bucketName: string): WrappedIcebergRestCatalog { + 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.'); + } + + const catalog = new IcebergRestCatalog({ + baseUrl: this.url, + catalogName: bucketName, + 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; + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/StorageBucketApi.ts b/packages/nativescript-supabase/supabase-storage/packages/StorageBucketApi.ts new file mode 100644 index 0000000..2939859 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/StorageBucketApi.ts @@ -0,0 +1,179 @@ +import { DEFAULT_HEADERS } from '../lib/constants'; +import { StorageError } from '../lib/common/errors'; +import { Fetch, get, post, put, remove } from '../lib/common/fetch'; +import BaseApiClient from '../lib/common/BaseApiClient'; +import { Bucket, BucketType, ListBucketOptions } from '../lib/types'; +import { StorageClientOptions } from '../StorageClient'; + +export default class StorageBucketApi extends BaseApiClient { + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch, opts?: StorageClientOptions) { + const baseUrl = new URL(url); + + if (opts?.useNewHostname) { + const isSupabaseHost = /supabase\.(co|in|red)$/.test(baseUrl.hostname); + if (isSupabaseHost && !baseUrl.hostname.includes('storage.supabase.')) { + baseUrl.hostname = baseUrl.hostname.replace('supabase.', 'storage.supabase.'); + } + } + + const finalUrl = baseUrl.href.replace(/\/$/, ''); + const finalHeaders = { ...DEFAULT_HEADERS, ...headers }; + + super(finalUrl, finalHeaders, fetch, 'storage'); + } + + async listBuckets(options?: ListBucketOptions): Promise< + | { + data: Bucket[]; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const queryString = this.listBucketOptionsToQueryString(options); + return await get(this.fetch, `${this.url}/bucket${queryString}`, { + headers: this.headers, + }); + }); + } + + async getBucket(id: string): Promise< + | { + data: Bucket; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await get(this.fetch, `${this.url}/bucket/${id}`, { headers: this.headers }); + }); + } + + async createBucket( + id: string, + options: { + public: boolean; + fileSizeLimit?: number | string | null; + allowedMimeTypes?: string[] | null; + type?: BucketType; + } = { + public: false, + }, + ): Promise< + | { + data: Pick; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await post( + this.fetch, + `${this.url}/bucket`, + { + id, + name: id, + type: options.type, + public: options.public, + file_size_limit: options.fileSizeLimit, + allowed_mime_types: options.allowedMimeTypes, + }, + { headers: this.headers }, + ); + }); + } + + async updateBucket( + id: string, + options: { + public: boolean; + fileSizeLimit?: number | string | null; + allowedMimeTypes?: string[] | null; + }, + ): Promise< + | { + data: { message: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await put( + this.fetch, + `${this.url}/bucket/${id}`, + { + id, + name: id, + public: options.public, + file_size_limit: options.fileSizeLimit, + allowed_mime_types: options.allowedMimeTypes, + }, + { headers: this.headers }, + ); + }); + } + + async emptyBucket(id: string): Promise< + | { + data: { message: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await post(this.fetch, `${this.url}/bucket/${id}/empty`, {}, { headers: this.headers }); + }); + } + + async deleteBucket(id: string): Promise< + | { + data: { message: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await remove(this.fetch, `${this.url}/bucket/${id}`, {}, { headers: this.headers }); + }); + } + + private listBucketOptionsToQueryString(options?: ListBucketOptions): string { + const params: Record = {}; + if (options) { + if ('limit' in options) { + params.limit = String(options.limit); + } + if ('offset' in options) { + params.offset = String(options.offset); + } + if (options.search) { + params.search = options.search; + } + if (options.sortColumn) { + params.sortColumn = options.sortColumn; + } + if (options.sortOrder) { + params.sortOrder = options.sortOrder; + } + } + return Object.keys(params).length > 0 ? '?' + new URLSearchParams(params).toString() : ''; + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/StorageFileApi.ts b/packages/nativescript-supabase/supabase-storage/packages/StorageFileApi.ts new file mode 100644 index 0000000..0b1eb4c --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/StorageFileApi.ts @@ -0,0 +1,557 @@ +import { StorageApiError, StorageError, StorageUnknownError, isStorageError } from '../lib/common/errors'; +import { get, head, post, put, remove, Fetch } from '../lib/common/fetch'; +import { setHeader } from '../lib/common/headers'; +import { recursiveToCamel } from '../lib/common/helpers'; +import BaseApiClient from '../lib/common/BaseApiClient'; +import { FileObject, FileOptions, SearchOptions, FetchParameters, TransformOptions, DestinationOptions, FileObjectV2, Camelize, SearchV2Options, SearchV2Result } from '../lib/types'; +import BlobDownloadBuilder from './BlobDownloadBuilder'; +import { Http, HTTPFormData, HTTPFormDataEntry } from '@klippa/nativescript-http'; + +const DEFAULT_SEARCH_OPTIONS = { + limit: 100, + offset: 0, + sortBy: { + column: 'name', + order: 'asc', + }, +}; + +const DEFAULT_FILE_OPTIONS: FileOptions = { + cacheControl: '3600', + contentType: 'text/plain;charset=UTF-8', + upsert: false, +}; + +type FileBody = ArrayBuffer | ArrayBufferView | Blob | Buffer | File | FormData | NodeJS.ReadableStream | ReadableStream | URLSearchParams | string; + +export default class StorageFileApi extends BaseApiClient { + protected bucketId: string | undefined; + + constructor(url: string, headers: { [key: string]: string } = {}, bucketId?: string, fetch?: Fetch) { + super(url, headers, fetch, 'storage'); + this.bucketId = bucketId; + } + + // NativeScript override: uses Http.request with HTTPFormData for native file upload support + private async uploadOrUpdate( + method: 'POST' | 'PUT', + path: string, + fileBody: FileBody, + fileOptions?: FileOptions, + ): Promise< + | { + data: { id: string; path: string; fullPath: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const body = new HTTPFormData(); + const options = { ...DEFAULT_FILE_OPTIONS, ...fileOptions }; + const headers: Record = { + ...this.headers, + ...(method === 'POST' && { 'x-upsert': String(options.upsert as boolean) }), + }; + + body.append('cacheControl', options.cacheControl as string); + + const metadata = options.metadata; + if (metadata) { + body.append('metadata', this.encodeMetadata(metadata)); + } + + let fileData; + if (typeof fileBody === 'string') { + if (global.isAndroid) { + fileData = new HTTPFormDataEntry(new java.io.File(fileBody)); + } else if (global.isIOS) { + fileData = new HTTPFormDataEntry(NSData.dataWithContentsOfURL(NSURL.URLWithString(fileBody))); + } + } else if (fileBody instanceof File) { + fileData = new HTTPFormDataEntry(fileBody, fileBody.name, fileBody.type); + } else { + fileData = new HTTPFormDataEntry(fileBody); + headers['cache-control'] = `max-age=${options.cacheControl}`; + headers['content-type'] = options.contentType as string; + if (metadata) { + headers['x-metadata'] = this.toBase64(this.encodeMetadata(metadata)); + } + } + body.append('', fileData); + + if (fileOptions?.headers) { + for (const [key, value] of Object.entries(fileOptions.headers)) { + headers[key.toLowerCase()] = value; + } + } + + const cleanPath = this._removeEmptyFolders(path); + const _path = this._getFinalPath(cleanPath); + const res = await Http.request({ + method: method, + url: `${this.url}/object/${_path}`, + content: body, + headers: { ...headers }, + }); + + if (res.statusCode >= 200 && res.statusCode <= 299) { + const data = res.content?.toJSON?.() as any; + return { path: cleanPath, id: data?.Id ?? '', fullPath: data?.Key ?? `${this.bucketId}/${cleanPath}` }; + } else { + const err = res.content?.toJSON?.() as any; + throw new StorageApiError(err?.message || err?.error || `Upload failed with status ${res.statusCode}`, res.statusCode, String(err?.statusCode || res.statusCode)); + } + }); + } + + async upload( + path: string, + fileBody: FileBody, + fileOptions?: FileOptions, + ): Promise< + | { + data: { id: string; path: string; fullPath: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.uploadOrUpdate('POST', path, fileBody, fileOptions); + } + + // NativeScript override: uses Http.request with HTTPFormData for native file upload support + async uploadToSignedUrl(path: string, token: string, fileBody: FileBody, fileOptions?: FileOptions) { + const cleanPath = this._removeEmptyFolders(path); + const _path = this._getFinalPath(cleanPath); + + const url = new URL(this.url + `/object/upload/sign/${_path}`); + url.searchParams.set('token', token); + + return this.handleOperation(async () => { + const body = new HTTPFormData(); + const options = { ...DEFAULT_FILE_OPTIONS, ...fileOptions }; + const headers: Record = { + ...this.headers, + ...{ 'x-upsert': String(options.upsert as boolean) }, + }; + + body.append('cacheControl', options.cacheControl as string); + + const metadata = options.metadata; + if (metadata) { + body.append('metadata', this.encodeMetadata(metadata)); + } + + let fileData; + if (typeof fileBody === 'string') { + if (global.isAndroid) { + fileData = new HTTPFormDataEntry(new java.io.File(fileBody)); + } else if (global.isIOS) { + fileData = new HTTPFormDataEntry(NSData.dataWithContentsOfURL(NSURL.URLWithString(fileBody))); + } + } else if (fileBody instanceof File) { + fileData = new HTTPFormDataEntry(fileBody, fileBody.name, fileBody.type); + } else { + fileData = new HTTPFormDataEntry(fileBody); + headers['cache-control'] = `max-age=${options.cacheControl}`; + headers['content-type'] = options.contentType as string; + if (metadata) { + headers['x-metadata'] = this.toBase64(this.encodeMetadata(metadata)); + } + } + body.append('', fileData); + + if (fileOptions?.headers) { + for (const [key, value] of Object.entries(fileOptions.headers)) { + headers[key.toLowerCase()] = value; + } + } + + const res = await Http.request({ + method: 'PUT', + url: url.toString(), + content: body, + headers: { ...headers }, + }); + + if (res.statusCode >= 200 && res.statusCode <= 299) { + const data = res.content?.toJSON?.() as any; + return { path: cleanPath, fullPath: data?.Key ?? `${this.bucketId}/${cleanPath}` }; + } else { + const err = res.content?.toJSON?.() as any; + throw new StorageApiError(err?.message || err?.error || `Upload failed with status ${res.statusCode}`, res.statusCode, String(err?.statusCode || res.statusCode)); + } + }); + } + + async createSignedUploadUrl( + path: string, + options?: { upsert: boolean }, + ): Promise< + | { + data: { signedUrl: string; token: string; path: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + let _path = this._getFinalPath(path); + + const headers = { ...this.headers }; + + if (options?.upsert) { + headers['x-upsert'] = 'true'; + } + + const data = await post(this.fetch, `${this.url}/object/upload/sign/${_path}`, {}, { headers }); + + const url = new URL(this.url + data.url); + + const token = url.searchParams.get('token'); + + if (!token) { + throw new StorageError('No token returned by API'); + } + + return { signedUrl: url.toString(), path, token }; + }); + } + + async update( + path: string, + fileBody: FileBody, + fileOptions?: FileOptions, + ): Promise< + | { + data: { id: string; path: string; fullPath: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.uploadOrUpdate('PUT', path, fileBody, fileOptions); + } + + async move( + fromPath: string, + toPath: string, + options?: DestinationOptions, + ): Promise< + | { + data: { message: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await post( + this.fetch, + `${this.url}/object/move`, + { + bucketId: this.bucketId, + sourceKey: fromPath, + destinationKey: toPath, + destinationBucket: options?.destinationBucket, + }, + { headers: this.headers }, + ); + }); + } + + async copy( + fromPath: string, + toPath: string, + options?: DestinationOptions, + ): Promise< + | { + data: { path: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const data = await post( + this.fetch, + `${this.url}/object/copy`, + { + bucketId: this.bucketId, + sourceKey: fromPath, + destinationKey: toPath, + destinationBucket: options?.destinationBucket, + }, + { headers: this.headers }, + ); + return { path: data.Key }; + }); + } + + async createSignedUrl( + path: string, + expiresIn: number, + options?: { + download?: string | boolean; + transform?: TransformOptions; + cacheNonce?: string; + }, + ): Promise< + | { + data: { signedUrl: string }; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + let _path = this._getFinalPath(path); + + const hasTransform = typeof options?.transform === 'object' && options.transform !== null && Object.keys(options.transform).length > 0; + + let data = await post(this.fetch, `${this.url}/object/sign/${_path}`, { expiresIn, ...(hasTransform ? { transform: options!.transform } : {}) }, { headers: this.headers }); + + const query = new URLSearchParams(); + if (options?.download) query.set('download', options.download === true ? '' : options.download); + if (options?.cacheNonce != null) query.set('cacheNonce', String(options.cacheNonce)); + const queryString = query.toString(); + + const signedUrl = encodeURI(`${this.url}${data.signedURL}${queryString ? `&${queryString}` : ''}`); + + return { signedUrl }; + }); + } + + async createSignedUrls( + paths: string[], + expiresIn: number, + options?: { download?: string | boolean; cacheNonce?: string }, + ): Promise< + | { + data: { error: string | null; path: string | null; signedUrl: string | null }[]; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const data = await post(this.fetch, `${this.url}/object/sign/${this.bucketId}`, { expiresIn, paths }, { headers: this.headers }); + + const query = new URLSearchParams(); + + if (options?.download) query.set('download', options.download === true ? '' : options.download); + if (options?.cacheNonce != null) query.set('cacheNonce', String(options.cacheNonce)); + + const queryString = query.toString(); + + return data.map((datum: { signedURL: string }) => ({ + ...datum, + signedUrl: datum.signedURL ? encodeURI(`${this.url}${datum.signedURL}${queryString ? `&${queryString}` : ''}`) : null, + })); + }); + } + + download(path: string, options?: Options, parameters?: FetchParameters): BlobDownloadBuilder { + const wantsTransformation = typeof options?.transform === 'object' && options.transform !== null && Object.keys(options.transform).length > 0; + const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object'; + + const query = new URLSearchParams(); + if (options?.transform) this.applyTransformOptsToQuery(query, options.transform); + if (options?.cacheNonce != null) query.set('cacheNonce', String(options.cacheNonce)); + const queryString = query.toString(); + + const _path = this._getFinalPath(path); + const downloadFn = () => + get( + this.fetch, + `${this.url}/${renderPath}/${_path}${queryString ? `?${queryString}` : ''}`, + { + headers: this.headers, + noResolveJson: true, + }, + parameters, + ); + return new BlobDownloadBuilder(downloadFn, this.shouldThrowOnError); + } + + async info(path: string): Promise< + | { + data: Camelize; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + const _path = this._getFinalPath(path); + + return this.handleOperation(async () => { + const data = await get(this.fetch, `${this.url}/object/info/${_path}`, { + headers: this.headers, + }); + + return recursiveToCamel(data) as Camelize; + }); + } + + async exists(path: string): Promise< + | { + data: boolean; + error: null; + } + | { + data: boolean; + error: StorageError; + } + > { + const _path = this._getFinalPath(path); + + try { + await head(this.fetch, `${this.url}/object/${_path}`, { + headers: this.headers, + }); + + return { data: true, error: null }; + } catch (error) { + if (this.shouldThrowOnError) { + throw error; + } + if (isStorageError(error)) { + const status = error instanceof StorageApiError ? error.status : error instanceof StorageUnknownError ? (error.originalError as { status: number })?.status : undefined; + + if (status !== undefined && [400, 404].includes(status)) { + return { data: false, error }; + } + } + + throw error; + } + } + + getPublicUrl( + path: string, + options?: { + download?: string | boolean; + transform?: TransformOptions; + cacheNonce?: string; + }, + ): { data: { publicUrl: string } } { + const _path = this._getFinalPath(path); + + const query = new URLSearchParams(); + if (options?.download) query.set('download', options.download === true ? '' : options.download); + if (options?.transform) this.applyTransformOptsToQuery(query, options.transform); + if (options?.cacheNonce != null) query.set('cacheNonce', String(options.cacheNonce)); + const queryString = query.toString(); + + const wantsTransformation = typeof options?.transform === 'object' && options.transform !== null && Object.keys(options.transform).length > 0; + const renderPath = wantsTransformation ? 'render/image' : 'object'; + + return { + data: { + publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}`) + (queryString ? `?${queryString}` : ''), + }, + }; + } + + async remove(paths: string[]): Promise< + | { + data: FileObject[]; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + return await remove(this.fetch, `${this.url}/object/${this.bucketId}`, { prefixes: paths }, { headers: this.headers }); + }); + } + + async list( + path?: string, + options?: SearchOptions, + parameters?: FetchParameters, + ): Promise< + | { + data: FileObject[]; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const body = { ...DEFAULT_SEARCH_OPTIONS, ...options, prefix: path || '' }; + return await post(this.fetch, `${this.url}/object/list/${this.bucketId}`, body, { headers: this.headers }, parameters); + }); + } + + async listV2( + options?: SearchV2Options, + parameters?: FetchParameters, + ): Promise< + | { + data: SearchV2Result; + error: null; + } + | { + data: null; + error: StorageError; + } + > { + return this.handleOperation(async () => { + const body = { ...options }; + return await post(this.fetch, `${this.url}/object/list-v2/${this.bucketId}`, body, { headers: this.headers }, parameters); + }); + } + + protected encodeMetadata(metadata: Record) { + return JSON.stringify(metadata); + } + + toBase64(data: string) { + if (typeof Buffer !== 'undefined') { + return Buffer.from(data).toString('base64'); + } + return btoa(data); + } + + private _getFinalPath(path: string) { + return `${this.bucketId}/${path.replace(/^\/+/, '')}`; + } + + private _removeEmptyFolders(path: string) { + return path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/'); + } + + private applyTransformOptsToQuery(query: URLSearchParams, transform: TransformOptions): URLSearchParams { + if (transform.width) query.set('width', transform.width.toString()); + if (transform.height) query.set('height', transform.height.toString()); + if (transform.resize) query.set('resize', transform.resize); + if (transform.format) query.set('format', transform.format); + if (transform.quality) query.set('quality', transform.quality.toString()); + + return query; + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/StorageVectorsClient.ts b/packages/nativescript-supabase/supabase-storage/packages/StorageVectorsClient.ts new file mode 100644 index 0000000..60dddb1 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/StorageVectorsClient.ts @@ -0,0 +1,122 @@ +import VectorIndexApi, { CreateIndexOptions } from './VectorIndexApi'; +import VectorDataApi from './VectorDataApi'; +import { Fetch } from '../lib/common/fetch'; +import VectorBucketApi from './VectorBucketApi'; +import { ApiResponse, DeleteVectorsOptions, GetVectorsOptions, ListIndexesOptions, ListVectorsOptions, ListVectorBucketsOptions, ListVectorBucketsResponse, PutVectorsOptions, QueryVectorsOptions, VectorBucket } from '../lib/types'; + +export interface StorageVectorsClientOptions { + headers?: { [key: string]: string }; + fetch?: Fetch; +} + +export class StorageVectorsClient extends VectorBucketApi { + constructor(url: string, options: StorageVectorsClientOptions = {}) { + super(url, options.headers || {}, options.fetch); + } + + from(vectorBucketName: string): VectorBucketScope { + return new VectorBucketScope(this.url, this.headers, vectorBucketName, this.fetch); + } + + async createBucket(vectorBucketName: string): Promise> { + return super.createBucket(vectorBucketName); + } + + async getBucket(vectorBucketName: string): Promise> { + return super.getBucket(vectorBucketName); + } + + async listBuckets(options: ListVectorBucketsOptions = {}): Promise> { + return super.listBuckets(options); + } + + async deleteBucket(vectorBucketName: string): Promise> { + return super.deleteBucket(vectorBucketName); + } +} + +export class VectorBucketScope extends VectorIndexApi { + private vectorBucketName: string; + + constructor(url: string, headers: { [key: string]: string }, vectorBucketName: string, fetch?: Fetch) { + super(url, headers, fetch); + this.vectorBucketName = vectorBucketName; + } + + override async createIndex(options: Omit) { + return super.createIndex({ + ...options, + vectorBucketName: this.vectorBucketName, + }); + } + + override async listIndexes(options: Omit = {}) { + return super.listIndexes({ + ...options, + vectorBucketName: this.vectorBucketName, + }); + } + + override async getIndex(indexName: string) { + return super.getIndex(this.vectorBucketName, indexName); + } + + override async deleteIndex(indexName: string) { + return super.deleteIndex(this.vectorBucketName, indexName); + } + + index(indexName: string): VectorIndexScope { + return new VectorIndexScope(this.url, this.headers, this.vectorBucketName, indexName, this.fetch); + } +} + +export class VectorIndexScope extends VectorDataApi { + private vectorBucketName: string; + private indexName: string; + + constructor(url: string, headers: { [key: string]: string }, vectorBucketName: string, indexName: string, fetch?: Fetch) { + super(url, headers, fetch); + this.vectorBucketName = vectorBucketName; + this.indexName = indexName; + } + + override async putVectors(options: Omit) { + return super.putVectors({ + ...options, + vectorBucketName: this.vectorBucketName, + indexName: this.indexName, + }); + } + + override async getVectors(options: Omit) { + return super.getVectors({ + ...options, + vectorBucketName: this.vectorBucketName, + indexName: this.indexName, + }); + } + + override async listVectors(options: Omit = {}) { + return super.listVectors({ + ...options, + vectorBucketName: this.vectorBucketName, + indexName: this.indexName, + }); + } + + override async queryVectors(options: Omit) { + return super.queryVectors({ + ...options, + vectorBucketName: this.vectorBucketName, + indexName: this.indexName, + }); + } + + override async deleteVectors(options: Omit) { + return super.deleteVectors({ + ...options, + vectorBucketName: this.vectorBucketName, + indexName: this.indexName, + }); + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/StreamDownloadBuilder.ts b/packages/nativescript-supabase/supabase-storage/packages/StreamDownloadBuilder.ts new file mode 100644 index 0000000..fb0a6bf --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/StreamDownloadBuilder.ts @@ -0,0 +1,52 @@ +import { isStorageError } from '../lib/common/errors'; +import { DownloadResult } from '../lib/types'; + +export default class StreamDownloadBuilder implements Promise> { + readonly [Symbol.toStringTag]: string = 'StreamDownloadBuilder'; + private promise: Promise> | null = null; + + constructor( + private downloadFn: () => Promise, + private shouldThrowOnError: boolean, + ) {} + + then, TResult2 = never>(onfulfilled?: ((value: DownloadResult) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null): Promise { + return this.getPromise().then(onfulfilled, onrejected); + } + + catch(onrejected?: ((reason: any) => TResult | PromiseLike) | null): Promise | TResult> { + return this.getPromise().catch(onrejected); + } + + finally(onfinally?: (() => void) | null): Promise> { + return this.getPromise().finally(onfinally); + } + + private getPromise(): Promise> { + if (!this.promise) { + this.promise = this.execute(); + } + return this.promise; + } + + private async execute(): Promise> { + try { + const result = await this.downloadFn(); + + return { + data: result.body as ReadableStream, + error: null, + }; + } catch (error) { + if (this.shouldThrowOnError) { + throw error; + } + + if (isStorageError(error)) { + return { data: null, error }; + } + + throw error; + } + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/VectorBucketApi.ts b/packages/nativescript-supabase/supabase-storage/packages/VectorBucketApi.ts new file mode 100644 index 0000000..e1785e4 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/VectorBucketApi.ts @@ -0,0 +1,41 @@ +import { DEFAULT_HEADERS } from '../lib/constants'; +import { StorageError } from '../lib/common/errors'; +import { Fetch, vectorsApi } from '../lib/common/fetch'; +import BaseApiClient from '../lib/common/BaseApiClient'; +import { ApiResponse, VectorBucket, ListVectorBucketsOptions, ListVectorBucketsResponse } from '../lib/types'; + +export default class VectorBucketApi extends BaseApiClient { + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { + const finalUrl = url.replace(/\/$/, ''); + const finalHeaders = { ...DEFAULT_HEADERS, 'Content-Type': 'application/json', ...headers }; + super(finalUrl, finalHeaders, fetch, 'vectors'); + } + + async createBucket(vectorBucketName: string): Promise> { + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/CreateVectorBucket`, { vectorBucketName }, { headers: this.headers }); + return data || {}; + }); + } + + async getBucket(vectorBucketName: string): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/GetVectorBucket`, { vectorBucketName }, { headers: this.headers }); + }); + } + + async listBuckets(options: ListVectorBucketsOptions = {}): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/ListVectorBuckets`, options, { + headers: this.headers, + }); + }); + } + + async deleteBucket(vectorBucketName: string): Promise> { + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/DeleteVectorBucket`, { vectorBucketName }, { headers: this.headers }); + return data || {}; + }); + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/VectorDataApi.ts b/packages/nativescript-supabase/supabase-storage/packages/VectorDataApi.ts new file mode 100644 index 0000000..80ace03 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/VectorDataApi.ts @@ -0,0 +1,74 @@ +import { DEFAULT_HEADERS } from '../lib/constants'; +import { StorageError } from '../lib/common/errors'; +import { Fetch, vectorsApi } from '../lib/common/fetch'; +import BaseApiClient from '../lib/common/BaseApiClient'; +import { ApiResponse, PutVectorsOptions, GetVectorsOptions, GetVectorsResponse, DeleteVectorsOptions, ListVectorsOptions, ListVectorsResponse, QueryVectorsOptions, QueryVectorsResponse } from '../lib/types'; + +export default class VectorDataApi extends BaseApiClient { + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { + const finalUrl = url.replace(/\/$/, ''); + const finalHeaders = { ...DEFAULT_HEADERS, 'Content-Type': 'application/json', ...headers }; + super(finalUrl, finalHeaders, fetch, 'vectors'); + } + + async putVectors(options: PutVectorsOptions): Promise> { + if (options.vectors.length < 1 || options.vectors.length > 500) { + throw new Error('Vector batch size must be between 1 and 500 items'); + } + + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/PutVectors`, options, { + headers: this.headers, + }); + return data || {}; + }); + } + + async getVectors(options: GetVectorsOptions): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/GetVectors`, options, { + headers: this.headers, + }); + }); + } + + async listVectors(options: ListVectorsOptions): Promise> { + if (options.segmentCount !== undefined) { + if (options.segmentCount < 1 || options.segmentCount > 16) { + throw new Error('segmentCount must be between 1 and 16'); + } + if (options.segmentIndex !== undefined) { + if (options.segmentIndex < 0 || options.segmentIndex >= options.segmentCount) { + throw new Error(`segmentIndex must be between 0 and ${options.segmentCount - 1}`); + } + } + } + + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/ListVectors`, options, { + headers: this.headers, + }); + }); + } + + async queryVectors(options: QueryVectorsOptions): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/QueryVectors`, options, { + headers: this.headers, + }); + }); + } + + async deleteVectors(options: DeleteVectorsOptions): Promise> { + if (options.keys.length < 1 || options.keys.length > 500) { + throw new Error('Keys batch size must be between 1 and 500 items'); + } + + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/DeleteVectors`, options, { + headers: this.headers, + }); + return data || {}; + }); + } +} diff --git a/packages/nativescript-supabase/supabase-storage/packages/VectorIndexApi.ts b/packages/nativescript-supabase/supabase-storage/packages/VectorIndexApi.ts new file mode 100644 index 0000000..e544af6 --- /dev/null +++ b/packages/nativescript-supabase/supabase-storage/packages/VectorIndexApi.ts @@ -0,0 +1,52 @@ +import { DEFAULT_HEADERS } from '../lib/constants'; +import { StorageError } from '../lib/common/errors'; +import { Fetch, vectorsApi } from '../lib/common/fetch'; +import BaseApiClient from '../lib/common/BaseApiClient'; +import { ApiResponse, VectorIndex, ListIndexesOptions, ListIndexesResponse, VectorDataType, DistanceMetric, MetadataConfiguration } from '../lib/types'; + +export interface CreateIndexOptions { + vectorBucketName: string; + indexName: string; + dataType: VectorDataType; + dimension: number; + distanceMetric: DistanceMetric; + metadataConfiguration?: MetadataConfiguration; +} + +export default class VectorIndexApi extends BaseApiClient { + constructor(url: string, headers: { [key: string]: string } = {}, fetch?: Fetch) { + const finalUrl = url.replace(/\/$/, ''); + const finalHeaders = { ...DEFAULT_HEADERS, 'Content-Type': 'application/json', ...headers }; + super(finalUrl, finalHeaders, fetch, 'vectors'); + } + + async createIndex(options: CreateIndexOptions): Promise> { + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/CreateIndex`, options, { + headers: this.headers, + }); + return data || {}; + }); + } + + async getIndex(vectorBucketName: string, indexName: string): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/GetIndex`, { vectorBucketName, indexName }, { headers: this.headers }); + }); + } + + async listIndexes(options: ListIndexesOptions): Promise> { + return this.handleOperation(async () => { + return await vectorsApi.post(this.fetch, `${this.url}/ListIndexes`, options, { + headers: this.headers, + }); + }); + } + + async deleteIndex(vectorBucketName: string, indexName: string): Promise> { + return this.handleOperation(async () => { + const data = await vectorsApi.post(this.fetch, `${this.url}/DeleteIndex`, { vectorBucketName, indexName }, { headers: this.headers }); + return data || {}; + }); + } +}