Compare commits

..

2 Commits

15 changed files with 2645 additions and 1563 deletions

View File

@@ -1,5 +1,107 @@
# Changelog
## 2025-11-20 - 4.0.0 - BREAKING CHANGE(core)
Make API strict-by-default: remove *Strict variants, throw on not-found/exists conflicts, add explicit exists() methods, update docs/tests and bump deps
- Breaking: Core API methods are strict by default and now throw errors instead of returning null when targets are missing or already exist (e.g. getBucketByName, getFile, getSubDirectoryByName, fastPut, fastPutStream).
- Removed *Strict variants: fastPutStrict, getBucketByNameStrict, getFileStrict, getSubDirectoryByNameStrict — use the base methods which are now strict.
- Added explicit existence checks: bucketExists (SmartBucket), fileExists (Directory/fileExists), directoryExists (Directory.directoryExists), and fastExists (Bucket.fastExists) to allow non-throwing checks before operations.
- Return type updates: fastPut now returns Promise<File> (no null), getBucketByName/getFile/getSubDirectoryByName now return the respective objects or throw.
- Improved error messages to guide callers (e.g. suggest setting overwrite:true on fastPut when object exists).
- Updated README, changelog and tests to reflect the new strict semantics and usage patterns.
- Developer/runtime dependency bumps: @git.zone/tsbuild, @git.zone/tsrun, @git.zone/tstest, @aws-sdk/client-s3, @push.rocks/smartstring, @tsclass/tsclass (version bumps recorded in package.json).
- Major version bump to 4.0.0 to reflect breaking API changes.
## 2025-11-20 - 4.0.0 - BREAKING: Strict by default + exists methods
Complete API overhaul: all methods throw by default, removed all *Strict variants, added dedicated exists methods
**Breaking Changes:**
**Putters (Write Operations):**
- `fastPut`: Return type `Promise<File | null>``Promise<File>`, throws when file exists and overwrite is false
- `fastPutStream`: Now throws when file exists and overwrite is false (previously returned silently)
- `fastPutStrict`: **Removed** - use `fastPut` directly
**Getters (Read Operations):**
- `getBucketByName`: Return type `Promise<Bucket | null>``Promise<Bucket>`, throws when bucket not found
- `getBucketByNameStrict`: **Removed** - use `getBucketByName` directly
- `getFile`: Return type `Promise<File | null>``Promise<File>`, throws when file not found
- `getFileStrict`: **Removed** - use `getFile` directly
- `getSubDirectoryByName`: Return type `Promise<Directory | null>``Promise<Directory>`, throws when directory not found
- `getSubDirectoryByNameStrict`: **Removed** - use `getSubDirectoryByName` directly
**New Methods (Existence Checks):**
- `bucket.fastExists({ path })` - ✅ Already existed
- `directory.fileExists({ path })` - **NEW** - Check if file exists
- `directory.directoryExists(name)` - **NEW** - Check if subdirectory exists
- `smartBucket.bucketExists(name)` - **NEW** - Check if bucket exists
**Benefits:**
-**Simpler API**: Removed 4 redundant *Strict methods
-**Type-safe**: No nullable returns - `Promise<T>` not `Promise<T | null>`
-**Fail-fast**: Errors throw immediately with precise stack traces
-**Consistent**: All methods behave the same way
-**Explicit**: Use exists() to check, then get() to retrieve
-**Better debugging**: Error location is always precise
**Migration Guide:**
```typescript
// ============================================
// Pattern 1: Check then Get (Recommended)
// ============================================
// Before (v3.x):
const bucket = await smartBucket.getBucketByName('my-bucket');
if (bucket) {
// use bucket
}
// After (v4.0):
if (await smartBucket.bucketExists('my-bucket')) {
const bucket = await smartBucket.getBucketByName('my-bucket'); // guaranteed to exist
// use bucket
}
// ============================================
// Pattern 2: Try/Catch
// ============================================
// Before (v3.x):
const file = await directory.getFile({ path: 'file.txt' });
if (!file) {
// Handle not found
}
// After (v4.0):
try {
const file = await directory.getFile({ path: 'file.txt' });
// use file
} catch (error) {
// Handle not found
}
// ============================================
// Pattern 3: Remove *Strict calls
// ============================================
// Before (v3.x):
const file = await directory.getFileStrict({ path: 'file.txt' });
// After (v4.0):
const file = await directory.getFile({ path: 'file.txt' }); // already strict
// ============================================
// Pattern 4: Write Operations
// ============================================
// Before (v3.x):
const file = await bucket.fastPutStrict({ path: 'file.txt', contents: 'data' });
// After (v4.0):
const file = await bucket.fastPut({ path: 'file.txt', contents: 'data' }); // already strict
```
## 2025-08-18 - 3.3.10 - fix(helpers)
Normalize and robustly parse S3 endpoint configuration; use normalized descriptor in SmartBucket and update dev tooling

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartbucket",
"version": "3.3.10",
"version": "4.0.0",
"description": "A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
@@ -12,22 +12,22 @@
"build": "(tsbuild --web --allowimplicitany)"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.7",
"@git.zone/tsrun": "^1.2.49",
"@git.zone/tstest": "^2.3.4",
"@git.zone/tsbuild": "^3.1.0",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.0.1",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/tapbundle": "^6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.864.0",
"@aws-sdk/client-s3": "^3.936.0",
"@push.rocks/smartmime": "^2.0.4",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smartunique": "^3.0.9",
"@tsclass/tsclass": "^9.2.0"
"@tsclass/tsclass": "^9.3.0"
},
"private": false,
"files": [

3958
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
* The project uses the official s3 client, not the minio client.
* notice the difference between *Strict methods and the normal methods.
* **All methods throw by default** (strict mode): - Put operations: `fastPut`, `fastPutStream` throw when file exists and overwrite is false - Get operations: `getBucketByName`, `getFile`, `getSubDirectoryByName` throw when not found
* **Use exists() methods to check before getting**: `bucketExists`, `fileExists`, `directoryExists`, `fastExists`
* **No *Strict methods**: All removed (fastPutStrict, getBucketByNameStrict, getFileStrict, getSubDirectoryByNameStrict)
* metadata is handled though the MetaData class. Important!

View File

@@ -88,8 +88,8 @@ console.log('🗑️ Bucket removed');
```typescript
const bucket = await smartBucket.getBucketByName('my-bucket');
// Simple file upload
await bucket.fastPut({
// Simple file upload (returns File object)
const file = await bucket.fastPut({
path: 'documents/report.pdf',
contents: Buffer.from('Your file content here')
});
@@ -100,12 +100,23 @@ await bucket.fastPut({
contents: 'Buy milk\nCall mom\nRule the world'
});
// Strict upload (returns File object)
const uploadedFile = await bucket.fastPutStrict({
// Upload with overwrite control
const uploadedFile = await bucket.fastPut({
path: 'images/logo.png',
contents: imageBuffer,
overwrite: true // Optional: control overwrite behavior
overwrite: true // Set to true to replace existing files
});
// Error handling: fastPut throws if file exists and overwrite is false
try {
await bucket.fastPut({
path: 'existing-file.txt',
contents: 'new content'
});
} catch (error) {
console.error('Upload failed:', error.message);
// Error: Object already exists at path 'existing-file.txt' in bucket 'my-bucket'. Set overwrite:true to replace it.
}
```
#### Download Files

View File

@@ -16,7 +16,7 @@ tap.test('should create a valid smartbucket', async () => {
endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
});
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByNameStrict(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
myBucket = await testSmartbucket.getBucketByName(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('test-pushrocks-smartbucket');
});

View File

@@ -17,7 +17,7 @@ tap.test('should create a valid smartbucket', async () => {
endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
});
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByNameStrict(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
myBucket = await testSmartbucket.getBucketByName(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('test-pushrocks-smartbucket');
});
@@ -30,7 +30,7 @@ tap.test('should clean all contents', async () => {
tap.test('should delete a file into the normally', async () => {
const path = 'trashtest/trashme.txt';
const file = await myBucket.fastPutStrict({
const file = await myBucket.fastPut({
path,
contents: 'I\'m in the trash test content!',
});
@@ -44,7 +44,7 @@ tap.test('should delete a file into the normally', async () => {
tap.test('should put a file into the trash', async () => {
const path = 'trashtest/trashme.txt';
const file = await myBucket.fastPutStrict({
const file = await myBucket.fastPut({
path,
contents: 'I\'m in the trash test content!',
});
@@ -76,7 +76,7 @@ tap.test('should put a file into the trash', async () => {
tap.test('should restore a file from trash', async () => {
const baseDirectory = await myBucket.getBaseDirectory();
const file = await baseDirectory.getFileStrict({
const file = await baseDirectory.getFile({
path: 'trashtest/trashme.txt',
getFromTrash: true
});

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartbucket',
version: '3.3.10',
version: '4.0.0',
description: 'A TypeScript library providing a cloud-agnostic interface for managing object storage with functionalities like bucket management, file and directory operations, and advanced features such as metadata handling and file locking.'
}

View File

@@ -14,7 +14,7 @@ import { Trash } from './classes.trash.js';
* operate in S3 basic fashion on blobs of data.
*/
export class Bucket {
public static async getBucketByName(smartbucketRef: SmartBucket, bucketNameArg: string) {
public static async getBucketByName(smartbucketRef: SmartBucket, bucketNameArg: string): Promise<Bucket> {
const command = new plugins.s3.ListBucketsCommand({});
const buckets = await smartbucketRef.s3Client.send(command);
const foundBucket = buckets.Buckets!.find((bucket) => bucket.Name === bucketNameArg);
@@ -24,8 +24,7 @@ export class Bucket {
console.log(`Taking this as base for new Bucket instance`);
return new this(smartbucketRef, bucketNameArg);
} else {
console.log(`did not find bucket by name: ${bucketNameArg}`);
return null;
throw new Error(`Bucket '${bucketNameArg}' not found.`);
}
}
@@ -71,7 +70,7 @@ export class Bucket {
}
const checkPath = await helpers.reducePathDescriptorToPath(pathDescriptorArg);
const baseDirectory = await this.getBaseDirectory();
return await baseDirectory.getSubDirectoryByNameStrict(checkPath, {
return await baseDirectory.getSubDirectoryByName(checkPath, {
getEmptyDirectory: true,
});
}
@@ -88,15 +87,16 @@ export class Bucket {
contents: string | Buffer;
overwrite?: boolean;
}
): Promise<File | null> {
): Promise<File> {
try {
const reducedPath = await helpers.reducePathDescriptorToPath(optionsArg);
const exists = await this.fastExists({ path: reducedPath });
if (exists && !optionsArg.overwrite) {
const errorText = `Object already exists at path '${reducedPath}' in bucket '${this.name}'.`;
console.error(errorText);
return null;
throw new Error(
`Object already exists at path '${reducedPath}' in bucket '${this.name}'. ` +
`Set overwrite:true to replace it.`
);
} else if (exists && optionsArg.overwrite) {
console.log(
`Overwriting existing object at path '${reducedPath}' in bucket '${this.name}'.`
@@ -129,13 +129,6 @@ export class Bucket {
}
}
public async fastPutStrict(...args: Parameters<Bucket['fastPut']>) {
const file = await this.fastPut(...args);
if (!file) {
throw new Error(`File not stored at path '${args[0].path}'`);
}
return file;
}
/**
* get file
@@ -259,10 +252,10 @@ export class Bucket {
const exists = await this.fastExists({ path: optionsArg.path });
if (exists && !optionsArg.overwrite) {
console.error(
`Object already exists at path '${optionsArg.path}' in bucket '${this.name}'.`
throw new Error(
`Object already exists at path '${optionsArg.path}' in bucket '${this.name}'. ` +
`Set overwrite:true to replace it.`
);
return;
} else if (exists && optionsArg.overwrite) {
console.log(
`Overwriting existing object at path '${optionsArg.path}' in bucket '${this.name}'.`
@@ -460,7 +453,7 @@ export class Bucket {
Range: `bytes=0-${optionsArg.length - 1}`,
});
const response = await this.smartbucketRef.s3Client.send(command);
const chunks = [];
const chunks: Buffer[] = [];
const stream = response.Body as any; // SdkStreamMixin includes readable stream
for await (const chunk of stream) {

View File

@@ -69,7 +69,7 @@ export class Directory {
path: string;
createWithContents?: string | Buffer;
getFromTrash?: boolean;
}): Promise<File | null> {
}): Promise<File> {
const pathDescriptor = {
directory: this,
path: optionsArg.path,
@@ -83,7 +83,7 @@ export class Directory {
return trashedFile;
}
if (!exists && !optionsArg.createWithContents) {
return null;
throw new Error(`File not found at path '${optionsArg.path}'`);
}
if (!exists && optionsArg.createWithContents) {
await File.create({
@@ -98,17 +98,26 @@ export class Directory {
});
}
/**
* gets a file strictly
* @param args
* @returns
* Check if a file exists in this directory
*/
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;
public async fileExists(optionsArg: { path: string }): Promise<boolean> {
const pathDescriptor = {
directory: this,
path: optionsArg.path,
};
return this.bucketRef.fastExists({
path: await helpers.reducePathDescriptorToPath(pathDescriptor),
});
}
/**
* Check if a subdirectory exists
*/
public async directoryExists(dirNameArg: string): Promise<boolean> {
const directories = await this.listDirectories();
return directories.some(dir => dir.name === dirNameArg);
}
/**
@@ -206,7 +215,7 @@ export class Directory {
* if the path is a file path, it will be treated as a file and the parent directory will be returned
*/
couldBeFilePath?: boolean;
} = {}): Promise<Directory | null> {
} = {}): Promise<Directory> {
const dirNameArray = dirNameArg.split('/').filter(str => str.trim() !== "");
@@ -253,16 +262,12 @@ export class Directory {
wantedDirectory = await getDirectory(directoryToSearchIn, dirNameToSearch, counter === dirNameArray.length);
}
return wantedDirectory || null;
if (!wantedDirectory) {
throw new Error(`Directory not found at path '${dirNameArg}'`);
}
return wantedDirectory;
}
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;
}
/**
* moves the directory
@@ -360,7 +365,7 @@ export class Directory {
*/
mode?: 'permanent' | 'trash';
}) {
const file = await this.getFileStrict({
const file = await this.getFile({
path: optionsArg.path,
});
await file.delete({

View File

@@ -245,7 +245,7 @@ export class File {
// lets update references of this
const baseDirectory = await this.parentDirectoryRef.bucketRef.getBaseDirectory();
this.parentDirectoryRef = await baseDirectory.getSubDirectoryByNameStrict(
this.parentDirectoryRef = await baseDirectory.getSubDirectoryByName(
await helpers.reducePathDescriptorToPath(pathDescriptorArg),
{
couldBeFilePath: true,

View File

@@ -17,7 +17,7 @@ export class MetaData {
metaData.fileRef = optionsArg.file;
// lets find the existing metadata file
metaData.metadataFile = await metaData.fileRef.parentDirectoryRef.getFileStrict({
metaData.metadataFile = await metaData.fileRef.parentDirectoryRef.getFile({
path: metaData.fileRef.name + '.metadata',
createWithContents: '{}',
});

View File

@@ -42,11 +42,12 @@ export class SmartBucket {
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;
/**
* Check if a bucket exists
*/
public async bucketExists(bucketNameArg: string): Promise<boolean> {
const command = new plugins.s3.ListBucketsCommand({});
const buckets = await this.s3Client.send(command);
return buckets.Buckets?.some(bucket => bucket.Name === bucketNameArg) ?? false;
}
}

View File

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