feat(bucket): Enhanced SmartBucket with trash management and metadata handling

This commit is contained in:
Philipp Kunz 2024-11-24 02:25:08 +01:00
parent 8d160cefb0
commit 34082c38a7
14 changed files with 847 additions and 979 deletions

View File

@ -1,5 +1,14 @@
# Changelog # Changelog
## 2024-11-24 - 3.2.0 - feat(bucket)
Enhanced SmartBucket with trash management and metadata handling
- Added functionality to move files to a trash directory.
- Introduced methods to handle file metadata more robustly.
- Implemented a method to clean all contents from a bucket.
- Enhanced directory retrieval to handle non-existent directories with options.
- Improved handling of file paths and metadata within the storage system.
## 2024-11-18 - 3.1.0 - feat(file) ## 2024-11-18 - 3.1.0 - feat(file)
Added functionality to retrieve magic bytes from files and detect file types using magic bytes. Added functionality to retrieve magic bytes from files and detect file types using magic bytes.

View File

@ -15,16 +15,16 @@
"@git.zone/tsbuild": "^2.1.84", "@git.zone/tsbuild": "^2.1.84",
"@git.zone/tsrun": "^1.2.49", "@git.zone/tsrun": "^1.2.49",
"@git.zone/tstest": "^1.0.90", "@git.zone/tstest": "^1.0.90",
"@push.rocks/qenv": "^6.0.5", "@push.rocks/qenv": "^6.1.0",
"@push.rocks/tapbundle": "^5.3.0" "@push.rocks/tapbundle": "^5.5.3"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/client-s3": "^3.699.0",
"@push.rocks/smartmime": "^2.0.4", "@push.rocks/smartmime": "^2.0.4",
"@push.rocks/smartpath": "^5.0.18", "@push.rocks/smartpath": "^5.0.18",
"@push.rocks/smartpromise": "^4.0.4", "@push.rocks/smartpromise": "^4.0.4",
"@push.rocks/smartrx": "^3.0.7", "@push.rocks/smartrx": "^3.0.7",
"@push.rocks/smartstream": "^3.2.4", "@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartstring": "^4.0.15", "@push.rocks/smartstring": "^4.0.15",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@tsclass/tsclass": "^4.1.2" "@tsclass/tsclass": "^4.1.2"

1542
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

0
test/helpers/prepare.ts Normal file
View File

7
test/test.metadata.ts Normal file
View File

@ -0,0 +1,7 @@
import { tap, expect } from '@push.rocks/tapbundle';
tap.test('test metadata functionality', async () => {
})
tap.start();

View File

@ -1,4 +1,5 @@
import { expect, expectAsync, tap } from '@push.rocks/tapbundle'; import { expect, expectAsync, tap } from '@push.rocks/tapbundle';
import { jestExpect } from '@push.rocks/tapbundle/node';
import { Qenv } from '@push.rocks/qenv'; import { Qenv } from '@push.rocks/qenv';
import * as smartbucket from '../ts/index.js'; import * as smartbucket from '../ts/index.js';
@ -11,28 +12,50 @@ let baseDirectory: smartbucket.Directory;
tap.test('should create a valid smartbucket', async () => { tap.test('should create a valid smartbucket', async () => {
testSmartbucket = new smartbucket.SmartBucket({ testSmartbucket = new smartbucket.SmartBucket({
accessKey: await testQenv.getEnvVarOnDemand('S3_KEY'), accessKey: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemand('S3_SECRET'), accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSSECRET'),
endpoint: await testQenv.getEnvVarOnDemand('S3_ENDPOINT'), endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
}); });
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket); expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByName('testzone'); myBucket = await testSmartbucket.getBucketByNameStrict(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket); expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('testzone'); expect(myBucket.name).toEqual('test-pushrocks-smartbucket');
});
tap.test('should clean all contents', async () => {
await myBucket.cleanAllContents();
expect(await myBucket.fastExists({ path: 'hithere/socool.txt' })).toBeFalse();
expect(await myBucket.fastExists({ path: 'trashtest/trashme.txt' })).toBeFalse();
});
tap.test('should delete a file into the normally', async () => {
const path = 'trashtest/trashme.txt';
const file = await myBucket.fastPut({
path,
contents: 'I\'m in the trash test content!',
});
const fileMetadata = await (await file.getMetaData()).metadataFile.getContents();
console.log(fileMetadata.toString());
expect(await file.getMetaData().then((meta) => meta.metadataFile.getJsonData())).toEqual({});
await file.delete({ mode: 'permanent' });
expect((await (await myBucket.getBaseDirectory()).listFiles()).length).toEqual(0);
expect((await (await myBucket.getBaseDirectory()).listDirectories()).length).toEqual(0);
}); });
tap.test('should put a file into the trash', async () => { tap.test('should put a file into the trash', async () => {
const path = 'hithere/socool.txt'; const path = 'trashtest/trashme.txt';
const file = await myBucket.fastPut({ const file = await myBucket.fastPut({
path, path,
contents: 'hi there!', contents: 'I\'m in the trash test content!',
}); });
const fileMetadata = await (await file.getMetaData()).metadataFile.getContents();
console.log(fileMetadata.toString());
expect(await file.getMetaData().then((meta) => meta.metadataFile.getJsonData())).toEqual({}); expect(await file.getMetaData().then((meta) => meta.metadataFile.getJsonData())).toEqual({});
await file.delete({ mode: 'trash' }); await file.delete({ mode: 'trash' });
expect(await file.getMetaData().then((meta) => meta.metadataFile.getJsonData())).toEqual({ jestExpect(await file.getMetaData().then((meta) => meta.metadataFile.getJsonData())).toEqual({
custom_recycle: { custom_recycle: {
deletedAt: 123, deletedAt: jestExpect.any(Number),
originalPath: 'hithere/socool.txt', originalPath: "trashtest/trashme.txt",
}, },
}); });
}); });

View File

@ -11,14 +11,20 @@ let baseDirectory: smartbucket.Directory;
tap.test('should create a valid smartbucket', async () => { tap.test('should create a valid smartbucket', async () => {
testSmartbucket = new smartbucket.SmartBucket({ testSmartbucket = new smartbucket.SmartBucket({
accessKey: await testQenv.getEnvVarOnDemand('S3_KEY'), accessKey: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemand('S3_SECRET'), accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSSECRET'),
endpoint: await testQenv.getEnvVarOnDemand('S3_ENDPOINT'), endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
}); });
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket); expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByName('testzone'); myBucket = await testSmartbucket.getBucketByNameStrict(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket); expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('testzone'); expect(myBucket.name).toEqual('test-pushrocks-smartbucket');
});
tap.test('should clean all contents', async () => {
await myBucket.cleanAllContents();
expect(await myBucket.fastExists({ path: 'hithere/socool.txt' })).toBeFalse();
expect(await myBucket.fastExists({ path: 'trashtest/trashme.txt' })).toBeFalse();
}); });
tap.skip.test('should create testbucket', async () => { tap.skip.test('should create testbucket', async () => {
@ -100,8 +106,9 @@ tap.test('should get base directory', async () => {
tap.test('should correctly build paths for sub directories', async () => { tap.test('should correctly build paths for sub directories', async () => {
const dir4 = await baseDirectory.getSubDirectoryByName('dir3/dir4'); const dir4 = await baseDirectory.getSubDirectoryByName('dir3/dir4');
expect(dir4).toBeInstanceOf(smartbucket.Directory); expect(dir4).toBeInstanceOf(smartbucket.Directory);
const dir4BasePath = dir4.getBasePath(); const dir4BasePath = dir4?.getBasePath();
console.log(dir4BasePath); console.log(dir4BasePath);
expect(dir4BasePath).toEqual('dir3/dir4/');
}); });
tap.test('clean up directory style tests', async () => { tap.test('clean up directory style tests', async () => {

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartbucket', name: '@push.rocks/smartbucket',
version: '3.1.0', version: '3.2.0',
description: 'A TypeScript library offering simple and cloud-agnostic object storage with advanced features like bucket creation, file and directory management, and data streaming.' description: 'A TypeScript library offering simple and cloud-agnostic object storage with advanced features like bucket creation, file and directory management, and data streaming.'
} }

View File

@ -52,7 +52,7 @@ export class Bucket {
* gets the base directory of the bucket * gets the base directory of the bucket
*/ */
public async getBaseDirectory(): Promise<Directory> { public async getBaseDirectory(): Promise<Directory> {
return new Directory(this, null, ''); return new Directory(this, null!, '');
} }
/** /**
@ -71,7 +71,9 @@ export class Bucket {
} }
const checkPath = await helpers.reducePathDescriptorToPath(pathDescriptorArg); const checkPath = await helpers.reducePathDescriptorToPath(pathDescriptorArg);
const baseDirectory = await this.getBaseDirectory(); const baseDirectory = await this.getBaseDirectory();
return await baseDirectory.getSubDirectoryByName(checkPath); return await baseDirectory.getSubDirectoryByNameStrict(checkPath, {
getEmptyDirectory: true,
});
} }
// =============== // ===============
@ -464,4 +466,52 @@ export class Bucket {
throw error; throw error;
} }
} }
public async cleanAllContents(): Promise<void> {
try {
// Define the command type explicitly
const listCommandInput: plugins.s3.ListObjectsV2CommandInput = {
Bucket: this.name,
};
let isTruncated = true;
let continuationToken: string | undefined = undefined;
while (isTruncated) {
// Add the continuation token to the input if present
const listCommand = new plugins.s3.ListObjectsV2Command({
...listCommandInput,
ContinuationToken: continuationToken,
});
// Explicitly type the response
const response: plugins.s3.ListObjectsV2Output =
await this.smartbucketRef.s3Client.send(listCommand);
console.log(`Cleaning contents of bucket '${this.name}': Now deleting ${response.Contents?.length} items...`);
if (response.Contents && response.Contents.length > 0) {
// Delete objects in batches, mapping each item to { Key: string }
const deleteCommand = new plugins.s3.DeleteObjectsCommand({
Bucket: this.name,
Delete: {
Objects: response.Contents.map((item) => ({ Key: item.Key! })),
Quiet: true,
},
});
await this.smartbucketRef.s3Client.send(deleteCommand);
}
// Update continuation token and truncation status
isTruncated = response.IsTruncated || false;
continuationToken = response.NextContinuationToken;
}
console.log(`All contents in bucket '${this.name}' have been deleted.`);
} catch (error) {
console.error(`Error cleaning contents of bucket '${this.name}':`, error);
throw error;
}
}
} }

View File

@ -69,7 +69,7 @@ export class Directory {
path: string; path: string;
createWithContents?: string | Buffer; createWithContents?: string | Buffer;
getFromTrash?: boolean; getFromTrash?: boolean;
}): Promise<File> { }): Promise<File | null> {
const pathDescriptor = { const pathDescriptor = {
directory: this, directory: this,
path: optionsArg.path, path: optionsArg.path,
@ -98,6 +98,19 @@ export class Directory {
}); });
} }
/**
* gets a file strictly
* @param args
* @returns
*/
public async getFileStrict(...args: Parameters<Directory['getFile']>) {
const file = await this.getFile(...args);
if (!file) {
throw new Error(`File not found at path '${args[0].path}'`);
}
return file;
}
/** /**
* lists all files * lists all files
*/ */
@ -110,7 +123,7 @@ export class Directory {
const response = await this.bucketRef.smartbucketRef.s3Client.send(command); const response = await this.bucketRef.smartbucketRef.s3Client.send(command);
const fileArray: File[] = []; const fileArray: File[] = [];
response.Contents.forEach((item) => { response.Contents?.forEach((item) => {
if (item.Key && !item.Key.endsWith('/')) { if (item.Key && !item.Key.endsWith('/')) {
const subtractedPath = item.Key.replace(this.getBasePath(), ''); const subtractedPath = item.Key.replace(this.getBasePath(), '');
if (!subtractedPath.includes('/')) { if (!subtractedPath.includes('/')) {
@ -178,23 +191,53 @@ export class Directory {
/** /**
* gets a sub directory by name * gets a sub directory by name
*/ */
public async getSubDirectoryByName(dirNameArg: string): Promise<Directory> { public async getSubDirectoryByName(dirNameArg: string, optionsArg: {
const dirNameArray = dirNameArg.split('/'); getEmptyDirectory?: boolean;
createWithInitializerFile?: boolean;
} = {}): Promise<Directory | null> {
const dirNameArray = dirNameArg.split('/').filter(str => str.trim() !== "");
const getDirectory = async (directoryArg: Directory, dirNameToSearch: string) => { optionsArg = {
const directories = await directoryArg.listDirectories(); getEmptyDirectory: false,
return directories.find((directory) => { createWithInitializerFile: false,
return directory.name === dirNameToSearch; ...optionsArg,
});
};
let wantedDirectory: Directory;
for (const dirNameToSearch of dirNameArray) {
const directoryToSearchIn = wantedDirectory ? wantedDirectory : this;
wantedDirectory = await getDirectory(directoryToSearchIn, dirNameToSearch);
} }
return wantedDirectory;
const getDirectory = async (directoryArg: Directory, dirNameToSearch: string, isFinalDirectory: boolean) => {
const directories = await directoryArg.listDirectories();
let returnDirectory = directories.find((directory) => {
return directory.name === dirNameToSearch;
});
if (returnDirectory) {
return returnDirectory;
}
if (optionsArg.getEmptyDirectory || optionsArg.createWithInitializerFile) {
returnDirectory = new Directory(this.bucketRef, this, dirNameToSearch);
}
if (isFinalDirectory && optionsArg.createWithInitializerFile) {
returnDirectory?.createEmptyFile('00init.txt');
}
return returnDirectory || null;
};
let wantedDirectory: Directory | null = null;
let counter = 0;
for (const dirNameToSearch of dirNameArray) {
counter++;
const directoryToSearchIn = wantedDirectory ? wantedDirectory : this;
wantedDirectory = await getDirectory(directoryToSearchIn, dirNameToSearch, counter === dirNameArray.length);
}
return wantedDirectory || null;
}
public async getSubDirectoryByNameStrict(...args: Parameters<Directory['getSubDirectoryByName']>) {
const directory = await this.getSubDirectoryByName(...args);
if (!directory) {
throw new Error(`Directory not found at path '${args[0]}'`);
}
return directory;
} }
/** /**

View File

@ -105,8 +105,10 @@ export class File {
path: this.getBasePath(), path: this.getBasePath(),
}); });
if (!this.name.endsWith('.metadata')) { if (!this.name.endsWith('.metadata')) {
const metadata = await this.getMetaData(); if (await this.hasMetaData()) {
await metadata.metadataFile.delete(optionsArg); const metadata = await this.getMetaData();
await metadata.metadataFile.delete(optionsArg);
}
} }
} else if (optionsArg.mode === 'trash') { } else if (optionsArg.mode === 'trash') {
const metadata = await this.getMetaData(); const metadata = await this.getMetaData();
@ -118,8 +120,9 @@ export class File {
}, },
}); });
const trash = await this.parentDirectoryRef.bucketRef.getTrash(); const trash = await this.parentDirectoryRef.bucketRef.getTrash();
const trashDir = await trash.getTrashDir();
await this.move({ await this.move({
directory: await trash.getTrashDir(), directory: trashDir,
path: await trash.getTrashKeyByOriginalBasePath(this.getBasePath()), path: await trash.getTrashKeyByOriginalBasePath(this.getBasePath()),
}); });
} }
@ -187,23 +190,49 @@ export class File {
* moves the file to another directory * moves the file to another directory
*/ */
public async move(pathDescriptorArg: interfaces.IPathDecriptor) { public async move(pathDescriptorArg: interfaces.IPathDecriptor) {
let moveToPath = ''; let moveToPath: string = '';
const isDirectory = await this.parentDirectoryRef.bucketRef.isDirectory(pathDescriptorArg); const isDirectory = await this.parentDirectoryRef.bucketRef.isDirectory(pathDescriptorArg);
if (isDirectory) { if (isDirectory) {
moveToPath = await helpers.reducePathDescriptorToPath({ moveToPath = await helpers.reducePathDescriptorToPath({
...pathDescriptorArg, ...pathDescriptorArg,
path: plugins.path.join(pathDescriptorArg.path!, this.name), path: plugins.path.join(pathDescriptorArg.path!, this.name),
}); });
} else {
moveToPath = await helpers.reducePathDescriptorToPath(pathDescriptorArg);
} }
// lets move the file // lets move the file
await this.parentDirectoryRef.bucketRef.fastMove({ await this.parentDirectoryRef.bucketRef.fastMove({
sourcePath: this.getBasePath(), sourcePath: this.getBasePath(),
destinationPath: moveToPath, destinationPath: moveToPath,
overwrite: true,
}); });
// lets move the metadatafile // lets move the metadatafile
const metadata = await this.getMetaData(); if (!this.name.endsWith('.metadata')) {
await metadata.metadataFile.move(pathDescriptorArg); const metadata = await this.getMetaData();
await this.parentDirectoryRef.bucketRef.fastMove({
sourcePath: metadata.metadataFile.getBasePath(),
destinationPath: moveToPath + '.metadata',
overwrite: true,
});
}
// lets update references of this
const baseDirectory = await this.parentDirectoryRef.bucketRef.getBaseDirectory();
this.parentDirectoryRef = await baseDirectory.getSubDirectoryByNameStrict(
pathDescriptorArg.directory?.getBasePath()!
);
this.name = pathDescriptorArg.path!;
}
public async hasMetaData(): Promise<boolean> {
if (!this.name.endsWith('.metadata')) {
const hasMetadataBool = MetaData.hasMetaData({
file: this,
});
return hasMetadataBool;
} else {
return false;
}
} }
/** /**

View File

@ -3,13 +3,21 @@ import * as plugins from './plugins.js';
import { File } from './classes.file.js'; import { File } from './classes.file.js';
export class MetaData { export class MetaData {
public static async hasMetaData(optionsArg: { file: File }) {
// lets find the existing metadata file
const existingFile = await optionsArg.file.parentDirectoryRef.getFile({
path: optionsArg.file.name + '.metadata',
});
return !!existingFile;
}
// static // static
public static async createForFile(optionsArg: { file: File }) { public static async createForFile(optionsArg: { file: File }) {
const metaData = new MetaData(); const metaData = new MetaData();
metaData.fileRef = optionsArg.file; metaData.fileRef = optionsArg.file;
// lets find the existing metadata file // lets find the existing metadata file
metaData.metadataFile = await metaData.fileRef.parentDirectoryRef.getFile({ metaData.metadataFile = await metaData.fileRef.parentDirectoryRef.getFileStrict({
path: metaData.fileRef.name + '.metadata', path: metaData.fileRef.name + '.metadata',
createWithContents: '{}', createWithContents: '{}',
}); });

View File

@ -41,7 +41,15 @@ export class SmartBucket {
await Bucket.removeBucketByName(this, bucketName); await Bucket.removeBucketByName(this, bucketName);
} }
public async getBucketByName(bucketName: string) { public async getBucketByName(bucketNameArg: string) {
return Bucket.getBucketByName(this, bucketName); return Bucket.getBucketByName(this, bucketNameArg);
}
public async getBucketByNameStrict(...args: Parameters<SmartBucket['getBucketByName']>) {
const bucket = await this.getBucketByName(...args);
if (!bucket) {
throw new Error(`Bucket ${args[0]} does not exist.`);
}
return bucket;
} }
} }

View File

@ -21,7 +21,7 @@ export class Trash {
const trashDir = await this.getTrashDir(); const trashDir = await this.getTrashDir();
const originalPath = await helpers.reducePathDescriptorToPath(pathDescriptor); const originalPath = await helpers.reducePathDescriptorToPath(pathDescriptor);
const trashKey = await this.getTrashKeyByOriginalBasePath(originalPath); const trashKey = await this.getTrashKeyByOriginalBasePath(originalPath);
return trashDir.getFile({ path: trashKey }); return trashDir.getFileStrict({ path: trashKey });
} }
public async getTrashKeyByOriginalBasePath (originalPath: string): Promise<string> { public async getTrashKeyByOriginalBasePath (originalPath: string): Promise<string> {