diff --git a/.changeset/copy-move-set-object-access.md b/.changeset/copy-move-set-object-access.md new file mode 100644 index 0000000..1fc6544 --- /dev/null +++ b/.changeset/copy-move-set-object-access.md @@ -0,0 +1,5 @@ +--- +'@tigrisdata/storage': minor +--- + +Add `copy(src, dest, options?)` and `move(src, dest, options?)` for copying and moving objects within or across buckets. Add `setObjectAccess(path, { access })` for changing object ACLs. Deprecate `updateObject`; use `setObjectAccess` for ACL changes and `move` for renames. diff --git a/AGENTS.md b/AGENTS.md index 521627f..f9bcad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,11 @@ ## Code Quality & Security +### Agent Behavior + +- **Never `git commit`, `git push`, or open a PR without an explicit, in-the-moment user instruction.** Treat each commit, push, and PR as a separate confirmation: approval for one does not extend to the next. When unsure, stop and ask. +- Stage proposed changes and surface them in chat first; let the user decide when (and whether) to commit. The same rule applies to fixup commits, follow-up commits, and changeset commits — none are implied by an earlier "commit" instruction. + ### Commit Guidelines Commit messages follow **Conventional Commits** format: diff --git a/biome.json b/biome.json index 0c9b0c3..c2ddbbe 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/packages/storage/src/lib/object/copy.ts b/packages/storage/src/lib/object/copy.ts new file mode 100644 index 0000000..97529c8 --- /dev/null +++ b/packages/storage/src/lib/object/copy.ts @@ -0,0 +1,86 @@ +import { TigrisHeaders } from '@shared/headers'; +import { handleError } from '@shared/utils'; +import { config, missingConfigError } from '../config'; +import { createStorageClient } from '../http-client'; +import type { TigrisStorageConfig, TigrisStorageResponse } from '../types'; + +export type CopyOptions = { + config?: TigrisStorageConfig; + /** Source bucket. Defaults to `config.bucket`. */ + srcBucket?: string; + /** Destination bucket. Defaults to `srcBucket` (same-bucket copy). */ + destBucket?: string; +}; + +export type CopyResponse = { + src: string; + dest: string; +}; + +export async function copy( + src: string, + dest: string, + options?: CopyOptions +): Promise> { + return copyOrMove(src, dest, false, options); +} + +export async function copyOrMove( + src: string, + dest: string, + rename: boolean, + options?: CopyOptions +): Promise> { + if (!src || !dest) { + return { error: new Error('src and dest are required') }; + } + + const srcBucket = + options?.srcBucket ?? options?.config?.bucket ?? config.bucket; + + if (!srcBucket) { + return missingConfigError('bucket'); + } + + const destBucket = options?.destBucket ?? srcBucket; + + if (srcBucket === destBucket && src === dest) { + return { error: new Error('src and dest must differ') }; + } + + const { data: storageHttpClient, error: storageHttpClientError } = + createStorageClient(options?.config); + + if (storageHttpClientError) { + return { error: storageHttpClientError }; + } + + const headers: Record = { + [TigrisHeaders.COPY_SOURCE]: `${srcBucket}/${encodeURIComponent(src)}`, + }; + + if (rename) { + headers[TigrisHeaders.RENAME] = 'true'; + } + + try { + const response = await storageHttpClient.request({ + method: 'PUT', + path: `/${destBucket}/${encodeURIComponent(dest)}?x-id=CopyObject`, + headers, + }); + + if (response.error) { + return { error: response.error }; + } + } catch (error) { + return handleError(error as Error); + } + + return { + data: { + src: `${srcBucket}/${src}`, + dest: `${destBucket}/${dest}`, + }, + }; +} diff --git a/packages/storage/src/lib/object/move.ts b/packages/storage/src/lib/object/move.ts new file mode 100644 index 0000000..47a0003 --- /dev/null +++ b/packages/storage/src/lib/object/move.ts @@ -0,0 +1,13 @@ +import type { TigrisStorageResponse } from '../types'; +import { type CopyOptions, type CopyResponse, copyOrMove } from './copy'; + +export type MoveOptions = CopyOptions; +export type MoveResponse = CopyResponse; + +export async function move( + src: string, + dest: string, + options?: MoveOptions +): Promise> { + return copyOrMove(src, dest, true, options); +} diff --git a/packages/storage/src/lib/object/set/access.ts b/packages/storage/src/lib/object/set/access.ts new file mode 100644 index 0000000..9e72bcd --- /dev/null +++ b/packages/storage/src/lib/object/set/access.ts @@ -0,0 +1,53 @@ +import { PutObjectAclCommand } from '@aws-sdk/client-s3'; +import { handleError } from '@shared/utils'; +import { config, missingConfigError } from '../../config'; +import { createTigrisClient } from '../../tigris-client'; +import type { TigrisStorageConfig, TigrisStorageResponse } from '../../types'; + +export type SetObjectAccessOptions = { + config?: TigrisStorageConfig; + access: 'public' | 'private'; +}; + +export type SetObjectAccessResponse = { + path: string; +}; + +export async function setObjectAccess( + path: string, + options: SetObjectAccessOptions +): Promise> { + if (!options?.access) { + return { error: new Error('No access option provided') }; + } + + const bucket = options?.config?.bucket ?? config.bucket; + + if (!bucket) { + return missingConfigError('bucket'); + } + + const { data: tigrisClient, error } = createTigrisClient(options?.config); + + if (error) { + return { error }; + } + + try { + await tigrisClient.send( + new PutObjectAclCommand({ + Bucket: bucket, + Key: path, + ACL: options.access === 'public' ? 'public-read' : 'private', + }) + ); + } catch (error) { + return handleError(error as Error); + } + + return { + data: { + path, + }, + }; +} diff --git a/packages/storage/src/lib/object/update.ts b/packages/storage/src/lib/object/update.ts index 362c626..e239f6c 100644 --- a/packages/storage/src/lib/object/update.ts +++ b/packages/storage/src/lib/object/update.ts @@ -16,6 +16,11 @@ export type UpdateObjectResponse = { path: string; }; +/** + * @deprecated Use `setObjectAccess` to change object ACLs, or the dedicated + * rename helper to change an object's key. `updateObject` will be removed in + * a future major version. + */ export async function updateObject( path: string, options?: UpdateObjectOptions diff --git a/packages/storage/src/server.ts b/packages/storage/src/server.ts index 63e6e93..15cbbb1 100644 --- a/packages/storage/src/server.ts +++ b/packages/storage/src/server.ts @@ -64,6 +64,7 @@ export { type BundleResponse, bundle, } from './lib/object/bundle'; +export { type CopyOptions, type CopyResponse, copy } from './lib/object/copy'; export { type GetOptions, type GetResponse, get } from './lib/object/get'; export { type HeadOptions, type HeadResponse, head } from './lib/object/head'; export { @@ -73,6 +74,7 @@ export { list, } from './lib/object/list'; export { isMigrated, type MigrateOptions, migrate } from './lib/object/migrate'; +export { type MoveOptions, type MoveResponse, move } from './lib/object/move'; export { type CompleteMultipartUploadOptions, type CompleteMultipartUploadResponse, @@ -91,6 +93,11 @@ export { } from './lib/object/presigned-url'; export { type PutOptions, type PutResponse, put } from './lib/object/put'; export { type RemoveOptions, remove } from './lib/object/remove'; +export { + type SetObjectAccessOptions, + type SetObjectAccessResponse, + setObjectAccess, +} from './lib/object/set/access'; export { type UpdateObjectOptions, type UpdateObjectResponse, diff --git a/packages/storage/src/test/integration.test.ts b/packages/storage/src/test/integration.test.ts index 7c8afe3..756c6ca 100644 --- a/packages/storage/src/test/integration.test.ts +++ b/packages/storage/src/test/integration.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { config } from '../lib/config'; +import { copy } from '../lib/object/copy'; import { get } from '../lib/object/get'; import { head } from '../lib/object/head'; import { list } from '../lib/object/list'; +import { move } from '../lib/object/move'; import { put } from '../lib/object/put'; import { remove } from '../lib/object/remove'; -import { updateObject } from '../lib/object/update'; +import { setObjectAccess } from '../lib/object/set/access'; import { shouldSkipIntegrationTests } from './setup'; const skipTests = shouldSkipIntegrationTests(); @@ -242,76 +244,115 @@ describe.skipIf(skipTests)('Tigris Storage Integration Tests', () => { }); }); - describe('updateObject', () => { - const updateFileName = `test-update-${Date.now()}.txt`; + describe('setObjectAccess', () => { + const accessFileName = `test-access-${Date.now()}.txt`; beforeEach(async () => { - await put(updateFileName, 'update test content', { config }); + await put(accessFileName, 'access test content', { config }); }); afterEach(async () => { - // Clean up both old and potentially renamed files - await remove(updateFileName, { config }); + await remove(accessFileName, { 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 rename an object', async () => { - const newKey = `test-renamed-${Date.now()}.txt`; - const result = await updateObject(updateFileName, { - key: newKey, + it('should set access to public', async () => { + const result = await setObjectAccess(accessFileName, { + access: 'public', config, }); expect(result.error).toBeUndefined(); - expect(result.data?.path).toBe(newKey); - - // Rename back so subsequent tests still find the original file - await updateObject(newKey, { key: updateFileName, config }); + expect(result.data?.path).toBe(accessFileName); }); - it('should update access to public', async () => { - const result = await updateObject(updateFileName, { - access: 'public', + it('should set access to private', async () => { + const result = await setObjectAccess(accessFileName, { + access: 'private', config, }); expect(result.error).toBeUndefined(); - expect(result.data?.path).toBe(updateFileName); + expect(result.data?.path).toBe(accessFileName); }); + }); - it('should update access to private', async () => { - const result = await updateObject(updateFileName, { - access: 'private', - config, - }); + describe('copy', () => { + const srcFileName = `test-copy-src-${Date.now()}.txt`; + const srcContent = 'copy test content'; + const createdKeys: string[] = []; + + beforeEach(async () => { + await put(srcFileName, srcContent, { config }); + }); + + afterEach(async () => { + await remove(srcFileName, { config }); + await Promise.all(createdKeys.map((key) => remove(key, { config }))); + createdKeys.length = 0; + }); + + it('should return error when src and dest are identical', async () => { + const result = await copy(srcFileName, srcFileName, { config }); + expect(result.error?.message).toBe('src and dest must differ'); + }); + + it('should copy an object and leave the source intact', async () => { + const destKey = `test-copy-dest-${Date.now()}.txt`; + createdKeys.push(destKey); + + const result = await copy(srcFileName, destKey, { config }); expect(result.error).toBeUndefined(); - expect(result.data?.path).toBe(updateFileName); + expect(result.data?.src).toBe(`${config.bucket}/${srcFileName}`); + expect(result.data?.dest).toBe(`${config.bucket}/${destKey}`); + + // Source still exists. + const srcHead = await head(srcFileName, { config }); + expect(srcHead.data).toBeDefined(); + + // Destination has the same content. + const destGet = await get(destKey, 'string', { config }); + expect(destGet.data).toBe(srcContent); }); + }); - it('should rename and update access together', async () => { - const newKey = `test-renamed-public-${Date.now()}.txt`; + describe('move', () => { + const srcFileName = `test-move-src-${Date.now()}.txt`; + const srcContent = 'move test content'; + const createdKeys: string[] = []; - const result = await updateObject(updateFileName, { - key: newKey, - access: 'public', - config, - }); + beforeEach(async () => { + await put(srcFileName, srcContent, { config }); + }); + + afterEach(async () => { + await remove(srcFileName, { config }); + await Promise.all(createdKeys.map((key) => remove(key, { config }))); + createdKeys.length = 0; + }); + + it('should return error when src and dest are identical', async () => { + const result = await move(srcFileName, srcFileName, { config }); + expect(result.error?.message).toBe('src and dest must differ'); + }); + + it('should move an object and remove the source', async () => { + const destKey = `test-move-dest-${Date.now()}.txt`; + createdKeys.push(destKey); + + const result = await move(srcFileName, destKey, { config }); expect(result.error).toBeUndefined(); - expect(result.data?.path).toBe(newKey); + expect(result.data?.src).toBe(`${config.bucket}/${srcFileName}`); + expect(result.data?.dest).toBe(`${config.bucket}/${destKey}`); - // Rename back so subsequent tests still find the original file - await updateObject(newKey, { - key: updateFileName, - access: 'private', - config, - }); + // Source no longer exists. + const srcHead = await head(srcFileName, { config }); + expect(srcHead.data).toBeUndefined(); + + // Destination has the original content. + const destGet = await get(destKey, 'string', { config }); + expect(destGet.data).toBe(srcContent); }); });