Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"@aws-sdk/credential-providers": "^3.1038.0",
"@smithy/shared-ini-file-loader": "^4.4.9",
"@tigrisdata/iam": "^2.1.1",
"@tigrisdata/storage": "^3.5.2",
"@tigrisdata/storage": "^3.6.0",
"commander": "^14.0.3",
"enquirer": "^2.4.1",
"jose": "^6.2.3",
Expand Down
90 changes: 73 additions & 17 deletions src/lib/objects/delete.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getStorageConfig } from '@auth/provider.js';
import { remove } from '@tigrisdata/storage';
import { listVersions, remove } from '@tigrisdata/storage';
import {
exitWithError,
failWithError,
Expand All @@ -18,6 +18,8 @@ import { resolveObjectArgs } from '@utils/path.js';

const context = msg('objects', 'delete');

type Target = { key: string; versionId?: string };

export default async function deleteObject(options: Record<string, unknown>) {
printStart(context);

Expand All @@ -26,11 +28,20 @@ export default async function deleteObject(options: Record<string, unknown>) {
const bucketArg = getOption<string>(options, ['bucket']);
const keysArg = getOption<string | string[]>(options, ['key']);
const force = getOption<boolean>(options, ['yes', 'y', 'force']);
const versionId = getOption<string>(options, ['version-id', 'versionId']);
const allVersions = !!getOption<boolean>(options, [
'all-versions',
'allVersions',
]);

if (!bucketArg) {
failWithError(context, 'Bucket name or path is required');
}

if (versionId && allVersions) {
failWithError(context, 'Cannot use --version-id with --all-versions');
}

const resolved = resolveObjectArgs(bucketArg);
const bucket = resolved.bucket;
const keys = keysArg || resolved.key || undefined;
Expand All @@ -40,35 +51,80 @@ export default async function deleteObject(options: Record<string, unknown>) {
}

const config = await getStorageConfig();
const bucketConfig = { ...config, bucket };
const keyList = Array.isArray(keys) ? keys : [keys];

if (versionId && keyList.length > 1) {
failWithError(
context,
'--version-id targets a single object; pass exactly one key'
);
}

// Resolve the list of (key, versionId?) targets to delete. By
// default we issue an unversioned DELETE per key (server creates a
// delete marker on versioned buckets). --version-id hard-deletes
// one specific version. --all-versions enumerates every version
// and every delete marker for each key and hard-deletes them all.
const targets: Target[] = [];
if (allVersions) {
for (const key of keyList) {
const { data, error } = await listVersions({
prefix: key,
config: bucketConfig,
});
if (error) {
failWithError(context, error);
}
const matchingVersions = data.versions.filter((v) => v.name === key);
const matchingMarkers = data.deleteMarkers.filter((m) => m.name === key);
for (const v of matchingVersions) {
targets.push({ key, versionId: v.versionId });
}
for (const m of matchingMarkers) {
targets.push({ key, versionId: m.versionId });
}
if (matchingVersions.length === 0 && matchingMarkers.length === 0) {
failWithError(
context,
`No versions or delete markers found for key '${key}'`
);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
} else if (versionId) {
targets.push({ key: keyList[0], versionId });
} else {
for (const key of keyList) targets.push({ key });
}

if (!force) {
requireInteractive('Use --yes to skip confirmation');
const confirmed = await confirm(
`Delete ${keyList.length} object(s) from '${bucket}'?`
);
const label = allVersions
? `Hard-delete ${targets.length} version(s) and delete marker(s) for ${keyList.length} object(s) from '${bucket}'?`
: versionId
? `Hard-delete version '${versionId}' of '${keyList[0]}' from '${bucket}'?`
: `Delete ${keyList.length} object(s) from '${bucket}'?`;
const confirmed = await confirm(label);
if (!confirmed) {
console.log('Aborted');
return;
}
}

const deleted: string[] = [];
const errors: { key: string; error: string }[] = [];
for (const key of keyList) {
const { error } = await remove(key, {
config: {
...config,
bucket,
},
const deleted: Target[] = [];
const errors: { key: string; versionId?: string; error: string }[] = [];
for (const target of targets) {
const { error } = await remove(target.key, {
...(target.versionId ? { versionId: target.versionId } : {}),
config: bucketConfig,
});

if (error) {
printFailure(context, error.message, { key });
errors.push({ key, error: error.message });
printFailure(context, error.message, target);
errors.push({ ...target, error: error.message });
} else {
deleted.push(key);
printSuccess(context, { key });
deleted.push(target);
printSuccess(context, target);
}
}

Expand All @@ -77,7 +133,7 @@ export default async function deleteObject(options: Record<string, unknown>) {
const jsonOutput: Record<string, unknown> = {
action: 'deleted',
bucket,
keys: deleted,
deleted,
Comment thread
cursor[bot] marked this conversation as resolved.
errors,
};
if (nextActions.length > 0) jsonOutput.nextActions = nextActions;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/objects/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export default async function getObject(options: Record<string, unknown>) {
'snapshotVersion',
'snapshot',
]);
const versionId = getOption<string>(options, ['version-id', 'versionId']);

if (!bucketArg) {
failWithError(context, 'Bucket name or path is required');
Expand All @@ -135,6 +136,7 @@ export default async function getObject(options: Record<string, unknown>) {
if (mode === 'stream') {
const { data, error } = await get(key, 'stream', {
...(snapshotVersion ? { snapshotVersion } : {}),
...(versionId ? { versionId } : {}),
config: {
...config,
bucket,
Expand Down Expand Up @@ -162,6 +164,7 @@ export default async function getObject(options: Record<string, unknown>) {
} else {
const { data, error } = await get(key, 'string', {
...(snapshotVersion ? { snapshotVersion } : {}),
...(versionId ? { versionId } : {}),
config: {
...config,
bucket,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/objects/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default async function objectInfo(options: Record<string, unknown>) {
'snapshotVersion',
'snapshot',
]);
const versionId = getOption<string>(options, ['version-id', 'versionId']);

if (!bucketArg) {
failWithError(context, 'Bucket name or path is required');
Expand All @@ -34,6 +35,7 @@ export default async function objectInfo(options: Record<string, unknown>) {

const { data, error } = await head(key, {
...(snapshotVersion ? { snapshotVersion } : {}),
...(versionId ? { versionId } : {}),
config: {
...config,
bucket,
Expand Down
144 changes: 144 additions & 0 deletions src/lib/objects/list-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { getStorageConfig } from '@auth/provider.js';
import { listVersions } from '@tigrisdata/storage';
import { failWithError } from '@utils/exit.js';
import {
formatJson,
formatSize,
formatTable,
formatXml,
type TableColumn,
} from '@utils/format.js';
import {
msg,
printEmpty,
printPaginationHint,
printStart,
printSuccess,
} from '@utils/messages.js';
import { getFormat, getOption, getPaginationOptions } from '@utils/options.js';
import { parseAnyPath } from '@utils/path.js';

const context = msg('objects', 'list-versions');

export default async function listObjectVersions(
options: Record<string, unknown>
) {
printStart(context);

const bucketArg = getOption<string>(options, ['bucket']);
const prefixFlag = getOption<string>(options, ['prefix', 'p', 'P']);
const delimiter = getOption<string>(options, ['delimiter', 'd']);
const keyMarker = getOption<string>(options, ['key-marker', 'keyMarker']);
const versionIdMarker = getOption<string>(options, [
'version-id-marker',
'versionIdMarker',
]);
const format = getFormat(options);
const { limit } = getPaginationOptions(options);

if (!bucketArg) {
failWithError(context, 'Bucket name is required');
}

const parsed = parseAnyPath(bucketArg);
const bucket = parsed.bucket;
const prefix = prefixFlag || parsed.path || undefined;

const config = await getStorageConfig();

const { data, error } = await listVersions({
prefix,
...(delimiter ? { delimiter } : {}),
...(limit !== undefined ? { limit } : {}),
...(keyMarker ? { keyMarker } : {}),
...(versionIdMarker ? { versionIdMarker } : {}),
config: {
...config,
bucket,
},
});

if (error) {
failWithError(context, error);
}

const versionRows = data.versions.map((v) => ({
key: v.name,
versionId: v.versionId,
latest: v.isLatest ? 'yes' : '',
size: formatSize(v.size),
modified: v.lastModified,
}));

const deleteMarkerRows = data.deleteMarkers.map((m) => ({
key: m.name,
versionId: m.versionId,
latest: m.isLatest ? 'yes' : '',
modified: m.lastModified,
}));

if (versionRows.length === 0 && deleteMarkerRows.length === 0) {
printEmpty(context);
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const versionColumns: TableColumn[] = [
{ key: 'key', header: 'Key' },
{ key: 'versionId', header: 'Version ID' },
{ key: 'latest', header: 'Latest' },
{ key: 'size', header: 'Size' },
{ key: 'modified', header: 'Modified' },
];

const deleteMarkerColumns: TableColumn[] = [
{ key: 'key', header: 'Key' },
{ key: 'versionId', header: 'Version ID' },
{ key: 'latest', header: 'Latest' },
{ key: 'modified', header: 'Modified' },
];

if (format === 'json') {
// Mirror the S3 ListObjectVersions response shape so downstream
// `jq` users get the same ergonomics as `aws s3api`.
console.log(
formatJson({
versions: data.versions,
deleteMarkers: data.deleteMarkers,
commonPrefixes: data.commonPrefixes,
nextKeyMarker: data.nextKeyMarker,
nextVersionIdMarker: data.nextVersionIdMarker,
hasMore: data.hasMore,
})
);
} else if (format === 'xml') {
const lines = ['<listVersions>'];
lines.push(
' ' +
formatXml(versionRows, 'versions', 'version').replace(/\n/g, '\n ')
);
lines.push(
' ' +
formatXml(deleteMarkerRows, 'deleteMarkers', 'deleteMarker').replace(
/\n/g,
'\n '
)
);
lines.push('</listVersions>');
console.log(lines.join('\n'));
} else {
if (versionRows.length > 0) {
console.log('\nVersions');
console.log(formatTable(versionRows, versionColumns));
}
if (deleteMarkerRows.length > 0) {
console.log('Delete Markers');
console.log(formatTable(deleteMarkerRows, deleteMarkerColumns));
}
printPaginationHint(data.nextKeyMarker);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

printSuccess(context, {
versions: versionRows.length,
deleteMarkers: deleteMarkerRows.length,
});
}
2 changes: 2 additions & 0 deletions src/lib/stat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default async function stat(options: Record<string, unknown>) {
'snapshotVersion',
'snapshot',
]);
const versionId = getOption<string>(options, ['version-id', 'versionId']);
const config = await getStorageConfig();

// No path: show overall stats
Expand Down Expand Up @@ -84,6 +85,7 @@ export default async function stat(options: Record<string, unknown>) {
// Object path: show object metadata
const { data, error } = await head(path, {
...(snapshotVersion ? { snapshotVersion } : {}),
...(versionId ? { versionId } : {}),
config: {
...config,
bucket,
Expand Down
Loading