diff --git a/packages/storage/src/lib/bucket/create.ts b/packages/storage/src/lib/bucket/create.ts index 744b89d..b47d043 100644 --- a/packages/storage/src/lib/bucket/create.ts +++ b/packages/storage/src/lib/bucket/create.ts @@ -32,6 +32,10 @@ export type CreateBucketOptions = { sourceBucketSnapshot?: string; access?: 'public' | 'private'; defaultTier?: StorageClass; + /** + * @deprecated This property is deprecated and will be removed in the next major version + * @see https://www.tigrisdata.com/docs/buckets/create-bucket/#bucket-consistency + */ consistency?: 'strict' | 'default'; region?: string | string[]; config?: Omit; diff --git a/packages/storage/src/lib/bucket/info.ts b/packages/storage/src/lib/bucket/info.ts index 64d4f00..664c415 100644 --- a/packages/storage/src/lib/bucket/info.ts +++ b/packages/storage/src/lib/bucket/info.ts @@ -1,8 +1,6 @@ -import { HeadBucketCommand } from '@aws-sdk/client-s3'; -import type { HttpResponse } from '@aws-sdk/types'; -import { TigrisHeaders } from '@shared/index'; -import { createTigrisClient } from '../tigris-client'; import type { TigrisStorageConfig, TigrisStorageResponse } from '../types'; +import { StorageClass } from './create'; +import { createStorageClient } from '../http-client'; export type GetBucketInfoOptions = { config?: TigrisStorageConfig; @@ -10,77 +8,128 @@ export type GetBucketInfoOptions = { export type BucketInfoResponse = { isSnapshotEnabled: boolean; + /** + * @deprecated + * @see forkInfo.hasChildren + * This property is deprecated and will be removed in the next major version + */ hasForks: boolean; + /** + * @deprecated + * @see forkInfo.parents[0].bucketName + * This property is deprecated and will be removed in the next major version + */ sourceBucketName?: string; + /** + * @deprecated + * @see forkInfo.parents[0].snapshot + * This property is deprecated and will be removed in the next major version + */ sourceBucketSnapshot?: string; + + forkInfo: + | { + hasChildren: boolean; + parents: Array<{ + bucketName: string; + forkCreatedAt: Date; + snapshot: string; + snapshotCreatedAt: Date; + }>; + } + | undefined; + settings: { + allowObjectAcl: boolean; + defaultTier: StorageClass; + }; + sizeInfo: { + numberOfObjects: number | undefined; + size: number | undefined; + numberOfObjectsAllVersions: number | undefined; + }; +}; + +type BucketInfoApiResponse = { + ForkInfo?: { + HasChildren: boolean; + Parents: Array<{ + BucketName: string; + ForkCreatedAt: string; + Snapshot: string; + SnapshotCreatedAt: string; + }>; + }; + name: string; + storage_class: StorageClass; + type?: 1; + tier_sizes: Record; + acl_settings?: { + allow_object_acl: boolean; + }; + estimated_unique_rows?: number; // number of objects + estimated_size?: number; // estimated size of the bucket in bytes + estimated_rows?: number; // estimated number of objects in the bucket (all versions) }; export async function getBucketInfo( bucketName: string, options?: GetBucketInfoOptions ): Promise> { - const { data: tigrisClient, error } = createTigrisClient( - options?.config, - true - ); + const { data: storageHttpClient, error: storageHttpClientError } = + createStorageClient(options?.config); - if (error) { - return { error }; + if (storageHttpClientError) { + return { error: storageHttpClientError }; } - const command = new HeadBucketCommand({ Bucket: bucketName }); + try { + const response = await storageHttpClient.request< + unknown, + BucketInfoApiResponse + >({ + method: 'GET', + path: `/${bucketName}?metadata&with-size=true`, + headers: { + Accept: 'application/json', + }, + }); - let headers: Record = {}; + if (response.error) { + return { error: response.error }; + } - command.middlewareStack.add( - (next) => async (args) => { - const result = await next(args); - headers = (result.response as HttpResponse).headers; + const data = { + isSnapshotEnabled: response.data.type === 1, + hasForks: response.data.ForkInfo?.HasChildren ?? false, + sourceBucketName: response.data.ForkInfo?.Parents?.[0]?.BucketName, + sourceBucketSnapshot: response.data.ForkInfo?.Parents?.[0]?.Snapshot, - return result; - }, - { step: 'deserialize' } - ); + forkInfo: response.data.ForkInfo + ? { + hasChildren: response.data.ForkInfo.HasChildren, + parents: response.data.ForkInfo.Parents?.map((parent) => ({ + bucketName: parent.BucketName, + forkCreatedAt: new Date(parent.ForkCreatedAt), + snapshot: parent.Snapshot, + snapshotCreatedAt: new Date(parent.SnapshotCreatedAt), + })) ?? [], + } + : undefined, + settings: { + allowObjectAcl: response.data.acl_settings?.allow_object_acl ?? false, + defaultTier: response.data.storage_class as StorageClass, + }, + sizeInfo: { + numberOfObjects: response.data.estimated_unique_rows ?? undefined, + size: response.data.estimated_size ?? undefined, + numberOfObjectsAllVersions: response.data.estimated_rows ?? undefined, + }, + }; - try { - return tigrisClient - .send(command) - .then(() => { - return { - data: { - isSnapshotEnabled: - headers[TigrisHeaders.SNAPSHOT_ENABLED.toLowerCase()] !== - undefined && - headers[TigrisHeaders.SNAPSHOT_ENABLED.toLowerCase()] === 'true', - hasForks: - headers[TigrisHeaders.HAS_FORKS.toLowerCase()] !== undefined && - headers[TigrisHeaders.HAS_FORKS.toLowerCase()] === 'true', - ...(headers[TigrisHeaders.FORK_SOURCE_BUCKET.toLowerCase()] !== - undefined - ? { - sourceBucketName: - headers[TigrisHeaders.FORK_SOURCE_BUCKET.toLowerCase()], - } - : {}), - ...(headers[ - TigrisHeaders.FORK_SOURCE_BUCKET_SNAPSHOT.toLowerCase() - ] !== undefined - ? { - sourceBucketSnapshot: - headers[ - TigrisHeaders.FORK_SOURCE_BUCKET_SNAPSHOT.toLowerCase() - ], - } - : {}), - }, - }; - }) - .catch((error) => { - return { - error: new Error(`Unable to get bucket info ${error.message}`), - }; - }); - } catch { - return { error: new Error('Unable to get bucket info') }; + return { data }; + } catch (error) { + return { + error: error instanceof Error ? error : new Error(String(error)), + }; } } diff --git a/packages/storage/src/lib/object/put.ts b/packages/storage/src/lib/object/put.ts index 596cc38..aec6362 100644 --- a/packages/storage/src/lib/object/put.ts +++ b/packages/storage/src/lib/object/put.ts @@ -112,10 +112,13 @@ export async function put( try { await upload.done(); - } catch (error: any) { + } catch (error: unknown) { + const { message } = error as { + message: string; + }; return { - error: error.message - ? new Error(error.message) + error: message + ? new Error(message) : new Error(`Unexpected error while uploading to Tigris Storage`), }; } diff --git a/packages/storage/src/lib/object/update.ts b/packages/storage/src/lib/object/update.ts new file mode 100644 index 0000000..6c1130b --- /dev/null +++ b/packages/storage/src/lib/object/update.ts @@ -0,0 +1,90 @@ +import { PutObjectAclCommand } from '@aws-sdk/client-s3'; +import { createTigrisClient } from '../tigris-client'; +import { TigrisHeaders } from '@shared/headers'; +import type { TigrisStorageConfig, TigrisStorageResponse } from '../types'; +import { config, missingConfigError } from '../config'; +import { handleError } from '../utils'; +import { createStorageClient } from '../http-client'; + +export type UpdateObjectOptions = { + config?: TigrisStorageConfig; + key?: string; + access?: 'public' | 'private'; +}; + +export type UpdateObjectResponse = { + path: string; +}; + +export async function updateObject( + path: string, + options?: UpdateObjectOptions +): Promise> { + if (!options?.key && !options?.access) { + return { error: new Error('No update options provided') }; + } + + const bucket = options?.config?.bucket ?? config.bucket; + + if (!bucket) { + return missingConfigError('bucket'); + } + + let currentKey = path; + + // Rename first, so the ACL update targets the correct key + if (options?.key) { + const key = options.key; + const { data: storageHttpClient, error: storageHttpClientError } = + createStorageClient(options?.config); + + if (storageHttpClientError) { + return { error: storageHttpClientError }; + } + + try { + const response = await storageHttpClient.request({ + method: 'PUT', + path: `/${bucket}/${encodeURIComponent(key)}?x-id=CopyObject`, + headers: { + [TigrisHeaders.COPY_SOURCE]: `${bucket}/${encodeURIComponent(path)}`, + [TigrisHeaders.RENAME]: 'true', + }, + }); + + if (response.error) { + return { error: response.error }; + } + + currentKey = key; + } catch (error) { + return handleError(error as Error); + } + } + + if (options?.access) { + const { data: tigrisClient, error } = createTigrisClient(options?.config); + + if (error) { + return { error }; + } + + try { + await tigrisClient.send( + new PutObjectAclCommand({ + Bucket: bucket, + Key: currentKey, + ACL: options.access === 'public' ? 'public-read' : 'private', + }) + ); + } catch (error) { + return handleError(error as Error); + } + } + + return { + data: { + path: currentKey, + }, + }; +} diff --git a/packages/storage/src/lib/stats.ts b/packages/storage/src/lib/stats.ts new file mode 100644 index 0000000..691a552 --- /dev/null +++ b/packages/storage/src/lib/stats.ts @@ -0,0 +1,128 @@ +import { createStorageClient } from './http-client'; +import type { TigrisStorageConfig, TigrisStorageResponse } from './types'; + +export type GetStatsOptions = { + config?: TigrisStorageConfig; +}; + +type BucketType = 'Regular' | 'Snapshot'; +type BucketVisibility = 'public' | 'private'; + +export type StatsResponse = { + stats: { + activeBuckets: number; + totalObjects: number; + totalStorageBytes: number; + totalUniqueObjects: number; + }; + buckets: Array<{ + name: string; + creationDate: Date; + forkInfo: + | { + hasChildren: boolean; + parents: Array<{ + bucketName: string; + forkCreatedAt: Date; + snapshot: string; + snapshotCreatedAt: Date; + }>; + } + | undefined; + type: BucketType; + regions: Array; + visibility: BucketVisibility; + }>; +}; + +type StatsApiResponse = { + Stats: { + ActiveBuckets: number; + TotalObjects: number; + TotalStorageBytes: number; + TotalUniqueObjects: number; + }; + Buckets: { + Bucket: Array<{ + CreationDate: string; + ForkInfo?: { + HasChildren: boolean; + Parents?: Array<{ + BucketName: string; + ForkCreatedAt: string; + Snapshot: string; + SnapshotCreatedAt: string; + }>; + }; + Name: string; + Regions?: string; + Type: 'Regular' | 'Snapshot'; + Visibility: { + IsPublic: boolean; + }; + }>; + }; +}; + +export async function getStats( + options?: GetStatsOptions +): Promise> { + const { data: storageHttpClient, error: storageHttpClientError } = + createStorageClient(options?.config); + + if (storageHttpClientError) { + return { error: storageHttpClientError }; + } + + try { + const response = await storageHttpClient.request( + { + method: 'GET', + path: `/?IncludeVisibility=true&IncludeOwnerInfo=true&IncludeRegionsInfo=true&IncludeTypeInfo=true&IncludeForkInfo=true&IncludeStats=true`, + headers: { + Accept: 'application/json', + }, + } + ); + + if (response.error) { + return { error: response.error }; + } + + const data = { + stats: { + activeBuckets: response.data.Stats.ActiveBuckets, + totalObjects: response.data.Stats.TotalObjects, + totalStorageBytes: response.data.Stats.TotalStorageBytes, + totalUniqueObjects: response.data.Stats.TotalUniqueObjects, + }, + buckets: response.data.Buckets.Bucket.map((bucket) => ({ + name: bucket.Name, + creationDate: new Date(bucket.CreationDate), + regions: bucket.Regions ? bucket.Regions.split(',') : ['global'], + type: bucket.Type as BucketType, + visibility: (bucket.Visibility.IsPublic === true + ? 'public' + : 'private') as BucketVisibility, + forkInfo: bucket.ForkInfo + ? { + hasChildren: bucket.ForkInfo.HasChildren, + parents: + bucket.ForkInfo.Parents?.map((parent) => ({ + bucketName: parent.BucketName, + forkCreatedAt: new Date(parent.ForkCreatedAt), + snapshot: parent.Snapshot, + snapshotCreatedAt: new Date(parent.SnapshotCreatedAt), + })) ?? [], + } + : undefined, + })), + }; + + return { data }; + } catch (error) { + return { + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/storage/src/lib/utils.ts b/packages/storage/src/lib/utils.ts index 74786b7..2573aac 100644 --- a/packages/storage/src/lib/utils.ts +++ b/packages/storage/src/lib/utils.ts @@ -4,3 +4,26 @@ export const addRandomSuffix = (path: string) => { const baseName = pathParts.join('.'); return `${baseName}-${new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 8)}${extension ? `.${extension}` : ''}`; }; + +export const handleError = (error: Error) => { + let errorMessage: string | undefined; + + if ((error as { Code?: string }).Code === 'AccessDenied') { + errorMessage = 'Access denied. Please check your credentials.'; + } + if ((error as { Code?: string }).Code === 'NoSuchKey') { + errorMessage = 'File not found in Tigris Storage'; + } + + if (errorMessage) { + return { + error: new Error(errorMessage), + }; + } + + return { + error: new Error( + error?.message || 'Unexpected error while processing request' + ), + }; +}; diff --git a/packages/storage/src/server.ts b/packages/storage/src/server.ts index e27edb1..f15ef0e 100644 --- a/packages/storage/src/server.ts +++ b/packages/storage/src/server.ts @@ -56,6 +56,16 @@ export { } from './lib/object/presigned-url'; export { put, type PutOptions, type PutResponse } from './lib/object/put'; export { remove, type RemoveOptions } from './lib/object/remove'; +export { + updateObject, + type UpdateObjectOptions, + type UpdateObjectResponse, +} from './lib/object/update'; +export { + getStats, + type GetStatsOptions, + type StatsResponse, +} from './lib/stats'; export { handleClientUpload, type ClientUploadRequest, diff --git a/packages/storage/src/test/integration.test.ts b/packages/storage/src/test/integration.test.ts index d8cd267..64963b7 100644 --- a/packages/storage/src/test/integration.test.ts +++ b/packages/storage/src/test/integration.test.ts @@ -4,6 +4,7 @@ import { get } from '../lib/object/get'; import { head } from '../lib/object/head'; import { list } from '../lib/object/list'; import { remove } from '../lib/object/remove'; +import { updateObject } from '../lib/object/update'; import { shouldSkipIntegrationTests } from './setup'; import { config } from '../lib/config'; @@ -241,6 +242,71 @@ describe.skipIf(skipTests)('Tigris Storage Integration Tests', () => { }); }); + describe('updateObject', () => { + const updateFileName = `test-update-${Date.now()}.txt`; + + beforeEach(async () => { + await put(updateFileName, 'update test content', { config }); + }); + + afterEach(async () => { + // Clean up both old and potentially renamed files + await remove(updateFileName, { config }); + }); + + it('should return error when no options provided', async () => { + const result = await updateObject(updateFileName); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('No update options provided'); + }); + + it('should return error on rename failure', async () => { + const newKey = `test-renamed-${Date.now()}.txt`; + const result = await updateObject(updateFileName, { + key: newKey, + config, + }); + + // Rename via HTTP client requires CopyObject permission + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + }); + + it('should update access to public', async () => { + const result = await updateObject(updateFileName, { + access: 'public', + config, + }); + + expect(result.error).toBeUndefined(); + expect(result.data?.path).toBe(updateFileName); + }); + + it('should update access to private', async () => { + const result = await updateObject(updateFileName, { + access: 'private', + config, + }); + + expect(result.error).toBeUndefined(); + expect(result.data?.path).toBe(updateFileName); + }); + + it('should not update access when rename fails', async () => { + const newKey = `test-renamed-public-${Date.now()}.txt`; + + // When rename fails, access update should not run + const result = await updateObject(updateFileName, { + key: newKey, + access: 'public', + config, + }); + + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + }); + }); + describe('end-to-end workflow', () => { it('should complete full upload -> get -> delete cycle', async () => { const fileName = `test-e2e-${Date.now()}.txt`; diff --git a/shared/headers.ts b/shared/headers.ts index 70945a9..c71be61 100644 --- a/shared/headers.ts +++ b/shared/headers.ts @@ -13,4 +13,6 @@ export enum TigrisHeaders { HAS_FORKS = 'X-Tigris-Is-Fork-Parent', FORK_SOURCE_BUCKET = 'X-Tigris-Fork-Source-Bucket', FORK_SOURCE_BUCKET_SNAPSHOT = 'X-Tigris-Fork-Source-Bucket-Snapshot', + RENAME = 'X-Tigris-Rename', + COPY_SOURCE = 'X-Amz-Copy-Source', }