Compare commits

..

4 Commits

16 changed files with 1223 additions and 116 deletions

View File

@@ -1,5 +1,72 @@
# Changelog # Changelog
## 2025-11-20 - 4.3.0 - feat(listing)
Add memory-efficient listing APIs: async generator, RxJS observable, and cursor pagination; export ListCursor and Minimatch; add minimatch dependency; bump to 4.2.0
- Added memory-efficient listing methods on Bucket: listAllObjects (async generator), listAllObjectsObservable (RxJS Observable), createCursor (returns ListCursor) and listAllObjectsArray (convenience array collector).
- New ListCursor class (ts/classes.listcursor.ts) providing page-based iteration: next(), hasMore(), reset(), getToken()/setToken().
- Added glob matching helper findByGlob(pattern) using minimatch (exported via plugins.Minimatch).
- Exported ListCursor from ts/index.ts and exported Minimatch via ts/plugins.ts.
- Added minimatch dependency in package.json and bumped package version to 4.2.0; increased test timeout to 120s.
- Updated tests to read S3_SECRETKEY, S3_PORT and to assert bucket name from env (test/test.node+deno.ts, test/test.trash.node+deno.ts).
- No breaking changes: new APIs are additive and existing behavior preserved.
## 2025-11-20 - 4.2.0 - feat(listing)
Add memory-efficient listing with async generators, RxJS observables, and cursor pagination for huge buckets
**New Memory-Efficient Listing Methods:**
**Async Generator (Recommended for most use cases):**
- `Bucket.listAllObjects(prefix?)` - Stream object keys one at a time using `for await...of`
- `Bucket.findByGlob(pattern)` - Find objects matching glob patterns (e.g., `**/*.json`, `npm/packages/*/index.json`)
- Memory efficient, supports early termination, composable
**RxJS Observable (For complex reactive pipelines):**
- `Bucket.listAllObjectsObservable(prefix?)` - Emit keys as Observable for use with RxJS operators (filter, map, take, etc.)
- Perfect for complex data transformations and reactive architectures
**Cursor Pattern (For manual pagination control):**
- `Bucket.createCursor(prefix?, options?)` - Create cursor for explicit page-by-page iteration
- `ListCursor.next()` - Fetch next page of results
- `ListCursor.hasMore()` - Check if more results available
- `ListCursor.reset()` - Reset to beginning
- `ListCursor.getToken()` / `ListCursor.setToken()` - Save/restore pagination state
- Ideal for UI pagination and resumable operations
**Convenience Methods:**
- `Bucket.listAllObjectsArray(prefix?)` - Collect all keys into array (WARNING: loads all into memory)
**Benefits:**
- ✅ Memory-efficient streaming for buckets with millions of objects
- ✅ Three patterns for different use cases (generators, observables, cursors)
- ✅ Support for early termination and incremental processing
- ✅ Glob pattern matching with minimatch
- ✅ Full TypeScript support with proper types
- ✅ Zero breaking changes - all new methods
**Dependencies:**
- Added `minimatch` for glob pattern support
**Files Changed:**
- `ts/classes.bucket.ts` - Added all listing methods
- `ts/classes.listcursor.ts` - NEW: Cursor implementation
- `ts/plugins.ts` - Export Minimatch
- `ts/index.ts` - Export ListCursor
- `test/test.listing.node+deno.ts` - NEW: Comprehensive listing tests
- `package.json` - Added minimatch dependency
## 2025-11-20 - 4.1.0 - feat(core)
Add S3 endpoint normalization, directory pagination, improved metadata checks, trash support, and related tests
- Add normalizeS3Descriptor helper to sanitize and normalize various S3 endpoint formats and emit warnings for mismatches (helpers.ts).
- Use normalized endpoint and credentials when constructing S3 client in SmartBucket (classes.smartbucket.ts).
- Implement paginated listing helper listObjectsV2AllPages in Directory and use it for listFiles and listDirectories to aggregate Contents and CommonPrefixes across pages (classes.directory.ts).
- Improve MetaData.hasMetaData to catch NotFound errors and return false instead of throwing (classes.metadata.ts).
- Export metadata and trash modules from index (ts/index.ts) and add a Trash class with utilities for trashed files and key encoding (classes.trash.ts).
- Enhance Bucket operations: fastCopy now preserves or replaces native metadata correctly, cleanAllContents supports paginated deletion, and improved fastExists error handling (classes.bucket.ts).
- Fix Directory.getSubDirectoryByName to construct new Directory instances with the correct parent directory reference.
- Add tests covering metadata absence and pagination behavior (test/test.local.node+deno.ts).
## 2025-11-20 - 4.0.1 - fix(plugins) ## 2025-11-20 - 4.0.1 - fix(plugins)
Use explicit node: imports for native path and stream modules in ts/plugins.ts Use explicit node: imports for native path and stream modules in ts/plugins.ts

6
deno.lock generated
View File

@@ -14,7 +14,8 @@
"npm:@push.rocks/smartstring@^4.1.0": "4.1.0", "npm:@push.rocks/smartstring@^4.1.0": "4.1.0",
"npm:@push.rocks/smartunique@^3.0.9": "3.0.9", "npm:@push.rocks/smartunique@^3.0.9": "3.0.9",
"npm:@push.rocks/tapbundle@^6.0.3": "6.0.3", "npm:@push.rocks/tapbundle@^6.0.3": "6.0.3",
"npm:@tsclass/tsclass@^9.3.0": "9.3.0" "npm:@tsclass/tsclass@^9.3.0": "9.3.0",
"npm:minimatch@^10.1.1": "10.1.1"
}, },
"npm": { "npm": {
"@api.global/typedrequest-interfaces@2.0.2": { "@api.global/typedrequest-interfaces@2.0.2": {
@@ -8113,7 +8114,8 @@
"npm:@push.rocks/smartstring@^4.1.0", "npm:@push.rocks/smartstring@^4.1.0",
"npm:@push.rocks/smartunique@^3.0.9", "npm:@push.rocks/smartunique@^3.0.9",
"npm:@push.rocks/tapbundle@^6.0.3", "npm:@push.rocks/tapbundle@^6.0.3",
"npm:@tsclass/tsclass@^9.3.0" "npm:@tsclass/tsclass@^9.3.0",
"npm:minimatch@^10.1.1"
] ]
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartbucket", "name": "@push.rocks/smartbucket",
"version": "4.0.1", "version": "4.3.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.", "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", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -8,7 +8,7 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test": "(tstest test/ --verbose --logfile --timeout 60)", "test": "(tstest test/ --verbose --logfile --timeout 120)",
"build": "(tsbuild --web --allowimplicitany)" "build": "(tsbuild --web --allowimplicitany)"
}, },
"devDependencies": { "devDependencies": {
@@ -27,7 +27,8 @@
"@push.rocks/smartstream": "^3.2.5", "@push.rocks/smartstream": "^3.2.5",
"@push.rocks/smartstring": "^4.1.0", "@push.rocks/smartstring": "^4.1.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@tsclass/tsclass": "^9.3.0" "@tsclass/tsclass": "^9.3.0",
"minimatch": "^10.1.1"
}, },
"private": false, "private": false,
"files": [ "files": [

15
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
minimatch:
specifier: ^10.1.1
version: 10.1.1
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^3.1.0 specifier: ^3.1.0
@@ -3562,10 +3565,6 @@ packages:
minimalistic-crypto-utils@1.0.1: minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.0.3:
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
engines: {node: 20 || >=22}
minimatch@10.1.1: minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -8144,7 +8143,7 @@ snapshots:
'@types/minimatch@6.0.0': '@types/minimatch@6.0.0':
dependencies: dependencies:
minimatch: 10.0.3 minimatch: 10.1.1
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
@@ -9315,7 +9314,7 @@ snapshots:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
jackspeak: 4.1.1 jackspeak: 4.1.1
minimatch: 10.0.3 minimatch: 10.1.1
minipass: 7.1.2 minipass: 7.1.2
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.0 path-scurry: 2.0.0
@@ -10257,10 +10256,6 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {} minimalistic-crypto-utils@1.0.1: {}
minimatch@10.0.3:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@10.1.1: minimatch@10.1.1:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.0 '@isaacs/brace-expansion': 5.0.0

537
readme.md
View File

@@ -1,39 +1,77 @@
# @push.rocks/smartbucket 🪣 # @push.rocks/smartbucket 🪣
> A powerful, cloud-agnostic TypeScript library for object storage with advanced features like file locking, metadata management, and intelligent trash handling. > A powerful, cloud-agnostic TypeScript library for object storage that makes S3 feel like a modern filesystem. Built for developers who demand simplicity, type-safety, and advanced features like metadata management, file locking, intelligent trash handling, and memory-efficient streaming.
## Why SmartBucket? 🎯
- **🌍 Cloud Agnostic** - Write once, run on AWS S3, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi, or any S3-compatible storage
- **🚀 Modern TypeScript** - First-class TypeScript support with complete type definitions and async/await patterns
- **💾 Memory Efficient** - Handle millions of files with async generators, RxJS observables, and cursor pagination
- **🗑️ Smart Trash System** - Recover accidentally deleted files with built-in trash and restore functionality
- **🔒 File Locking** - Prevent concurrent modifications with built-in locking mechanisms
- **🏷️ Rich Metadata** - Attach custom metadata to any file for powerful organization and search
- **🌊 Streaming Support** - Efficient handling of large files with Node.js and Web streams
- **📁 Directory-like API** - Intuitive filesystem-like operations on object storage
- **⚡ Fail-Fast** - Strict-by-default API catches errors immediately with precise stack traces
## Quick Start 🚀
```typescript
import { SmartBucket } from '@push.rocks/smartbucket';
// Connect to your storage
const storage = new SmartBucket({
accessKey: 'your-access-key',
accessSecret: 'your-secret-key',
endpoint: 's3.amazonaws.com',
port: 443,
useSsl: true
});
// Get or create a bucket
const bucket = await storage.getBucketByName('my-app-data');
// Upload a file
await bucket.fastPut({
path: 'users/profile.json',
contents: JSON.stringify({ name: 'Alice', role: 'admin' })
});
// Download it back
const data = await bucket.fastGet({ path: 'users/profile.json' });
console.log('📄', JSON.parse(data.toString()));
// List files efficiently (even with millions of objects!)
for await (const key of bucket.listAllObjects('users/')) {
console.log('🔍 Found:', key);
}
```
## Install 📦 ## Install 📦
To install `@push.rocks/smartbucket`, run:
```bash ```bash
# Using pnpm (recommended)
pnpm add @push.rocks/smartbucket
# Using npm
npm install @push.rocks/smartbucket --save npm install @push.rocks/smartbucket --save
``` ```
Or if you're using pnpm (recommended):
```bash
pnpm add @push.rocks/smartbucket
```
## Usage 🚀 ## Usage 🚀
### Introduction
`@push.rocks/smartbucket` provides a unified, cloud-agnostic API for object storage operations across major providers like AWS S3, Google Cloud Storage, MinIO, and more. It abstracts away provider-specific complexities while offering advanced features like metadata management, file locking, streaming operations, and intelligent trash management.
### Table of Contents ### Table of Contents
1. [🏁 Getting Started](#-getting-started) 1. [🏁 Getting Started](#-getting-started)
2. [🗂️ Working with Buckets](#-working-with-buckets) 2. [🗂️ Working with Buckets](#-working-with-buckets)
3. [📁 File Operations](#-file-operations) 3. [📁 File Operations](#-file-operations)
4. [📂 Directory Management](#-directory-management) 4. [📋 Memory-Efficient Listing](#-memory-efficient-listing)
5. [🌊 Streaming Operations](#-streaming-operations) 5. [📂 Directory Management](#-directory-management)
6. [🔒 File Locking](#-file-locking) 6. [🌊 Streaming Operations](#-streaming-operations)
7. [🏷️ Metadata Management](#-metadata-management) 7. [🔒 File Locking](#-file-locking)
8. [🗑 Trash & Recovery](#-trash--recovery) 8. [🏷 Metadata Management](#-metadata-management)
9. [⚡ Advanced Features](#-advanced-features) 9. [🗑️ Trash & Recovery](#-trash--recovery)
10. [☁️ Cloud Provider Support](#-cloud-provider-support) 10. [⚡ Advanced Features](#-advanced-features)
11. [☁️ Cloud Provider Support](#-cloud-provider-support)
### 🏁 Getting Started ### 🏁 Getting Started
@@ -53,6 +91,17 @@ const smartBucket = new SmartBucket({
}); });
``` ```
**For MinIO or self-hosted S3:**
```typescript
const smartBucket = new SmartBucket({
accessKey: 'minioadmin',
accessSecret: 'minioadmin',
endpoint: 'localhost',
port: 9000,
useSsl: false // MinIO often runs without SSL locally
});
```
### 🗂️ Working with Buckets ### 🗂️ Working with Buckets
#### Creating Buckets #### Creating Buckets
@@ -66,11 +115,14 @@ console.log(`✅ Bucket created: ${myBucket.name}`);
#### Getting Existing Buckets #### Getting Existing Buckets
```typescript ```typescript
// Get a bucket reference // Get a bucket reference (throws if not found - strict by default!)
const existingBucket = await smartBucket.getBucketByName('existing-bucket'); const existingBucket = await smartBucket.getBucketByName('existing-bucket');
// Or use strict mode (throws if bucket doesn't exist) // Check first, then get (non-throwing approach)
const bucketStrict = await smartBucket.getBucketByNameStrict('must-exist-bucket'); if (await smartBucket.bucketExists('maybe-exists')) {
const bucket = await smartBucket.getBucketByName('maybe-exists');
console.log('✅ Found bucket:', bucket.name);
}
``` ```
#### Removing Buckets #### Removing Buckets
@@ -93,6 +145,7 @@ const file = await bucket.fastPut({
path: 'documents/report.pdf', path: 'documents/report.pdf',
contents: Buffer.from('Your file content here') contents: Buffer.from('Your file content here')
}); });
console.log('✅ Uploaded:', file.path);
// Upload with string content // Upload with string content
await bucket.fastPut({ await bucket.fastPut({
@@ -114,8 +167,9 @@ try {
contents: 'new content' contents: 'new content'
}); });
} catch (error) { } catch (error) {
console.error('Upload failed:', error.message); 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. // Error: Object already exists at path 'existing-file.txt' in bucket 'my-bucket'.
// Set overwrite:true to replace it.
} }
``` ```
@@ -130,6 +184,9 @@ console.log(`📄 File size: ${fileContent.length} bytes`);
// Get file as string // Get file as string
const textContent = fileContent.toString('utf-8'); const textContent = fileContent.toString('utf-8');
// Parse JSON files directly
const jsonData = JSON.parse(fileContent.toString());
``` ```
#### Check File Existence #### Check File Existence
@@ -148,6 +205,7 @@ console.log(`File exists: ${exists ? '✅' : '❌'}`);
await bucket.fastRemove({ await bucket.fastRemove({
path: 'old-file.txt' path: 'old-file.txt'
}); });
console.log('🗑️ File deleted permanently');
``` ```
#### Copy & Move Files #### Copy & Move Files
@@ -158,14 +216,182 @@ await bucket.fastCopy({
sourcePath: 'original/file.txt', sourcePath: 'original/file.txt',
destinationPath: 'backup/file-copy.txt' destinationPath: 'backup/file-copy.txt'
}); });
console.log('📋 File copied');
// Move file (copy + delete original) // Move file (copy + delete original)
await bucket.fastMove({ await bucket.fastMove({
sourcePath: 'temp/draft.txt', sourcePath: 'temp/draft.txt',
destinationPath: 'final/document.txt' destinationPath: 'final/document.txt'
}); });
console.log('📦 File moved');
``` ```
### 📋 Memory-Efficient Listing
SmartBucket provides three powerful patterns for listing objects, optimized for handling **millions of files** efficiently:
#### Async Generators (Recommended) ⭐
Memory-efficient streaming using native JavaScript async iteration:
```typescript
// List all objects with prefix - streams one at a time!
for await (const key of bucket.listAllObjects('documents/')) {
console.log(`📄 Found: ${key}`);
// Process each file individually (memory efficient!)
const content = await bucket.fastGet({ path: key });
processFile(content);
// Early termination support
if (shouldStop()) break;
}
// List all objects (no prefix)
const allKeys: string[] = [];
for await (const key of bucket.listAllObjects()) {
allKeys.push(key);
}
// Find objects matching glob patterns
for await (const key of bucket.findByGlob('**/*.json')) {
console.log(`📦 JSON file: ${key}`);
}
// Complex glob patterns
for await (const key of bucket.findByGlob('npm/packages/*/index.json')) {
// Matches: npm/packages/foo/index.json, npm/packages/bar/index.json
console.log(`📦 Package index: ${key}`);
}
// More glob examples
for await (const key of bucket.findByGlob('logs/**/*.log')) {
console.log('📋 Log file:', key);
}
for await (const key of bucket.findByGlob('images/*.{jpg,png,gif}')) {
console.log('🖼️ Image:', key);
}
```
**Why use async generators?**
- ✅ Processes one item at a time (constant memory usage)
- ✅ Supports early termination with `break`
- ✅ Native JavaScript - no dependencies
- ✅ Perfect for large buckets with millions of objects
- ✅ Works seamlessly with `for await...of` loops
#### RxJS Observables
Perfect for reactive pipelines and complex data transformations:
```typescript
import { filter, take, map } from 'rxjs/operators';
// Stream keys as Observable with powerful operators
bucket.listAllObjectsObservable('logs/')
.pipe(
filter(key => key.endsWith('.log')),
take(100),
map(key => ({ key, timestamp: Date.now() }))
)
.subscribe({
next: (item) => console.log(`📋 Log file: ${item.key}`),
error: (err) => console.error('❌ Error:', err),
complete: () => console.log('✅ Listing complete')
});
// Simple subscription without operators
bucket.listAllObjectsObservable('data/')
.subscribe({
next: (key) => processKey(key),
complete: () => console.log('✅ Done')
});
// Combine with other observables
import { merge } from 'rxjs';
const logs$ = bucket.listAllObjectsObservable('logs/');
const backups$ = bucket.listAllObjectsObservable('backups/');
merge(logs$, backups$)
.pipe(filter(key => key.includes('2024')))
.subscribe(key => console.log('📅 2024 file:', key));
```
**Why use observables?**
- ✅ Rich operator ecosystem (filter, map, debounce, etc.)
- ✅ Composable with other RxJS streams
- ✅ Perfect for reactive architectures
- ✅ Great for complex transformations
#### Cursor Pattern
Explicit pagination control for UI and resumable operations:
```typescript
// Create cursor with custom page size
const cursor = bucket.createCursor('uploads/', { pageSize: 100 });
// Fetch pages manually
while (cursor.hasMore()) {
const page = await cursor.next();
console.log(`📄 Page has ${page.keys.length} items`);
for (const key of page.keys) {
console.log(` - ${key}`);
}
if (page.done) {
console.log('✅ Reached end');
break;
}
}
// Save and restore cursor state (perfect for resumable operations!)
const token = cursor.getToken();
// Store token in database or session...
// ... later, in a different request ...
const newCursor = bucket.createCursor('uploads/', { pageSize: 100 });
newCursor.setToken(token); // Resume from saved position!
const nextPage = await cursor.next();
// Reset cursor to start over
cursor.reset();
const firstPage = await cursor.next(); // Back to the beginning
```
**Why use cursors?**
- ✅ Perfect for UI pagination (prev/next buttons)
- ✅ Save/restore state for resumable operations
- ✅ Explicit control over page fetching
- ✅ Great for implementing "Load More" buttons
#### Convenience Methods
```typescript
// Collect all keys into array (⚠️ WARNING: loads everything into memory!)
const allKeys = await bucket.listAllObjectsArray('images/');
console.log(`📦 Found ${allKeys.length} images`);
// Only use for small result sets
const smallList = await bucket.listAllObjectsArray('config/');
if (smallList.length < 100) {
// Safe to process in memory
smallList.forEach(key => console.log(key));
}
```
**Performance Comparison:**
| Method | Memory Usage | Best For | Supports Early Exit |
|--------|-------------|----------|-------------------|
| **Async Generator** | O(1) - constant | Most use cases, large datasets | ✅ Yes |
| **Observable** | O(1) - constant | Reactive pipelines, RxJS apps | ✅ Yes |
| **Cursor** | O(pageSize) | UI pagination, resumable ops | ✅ Yes |
| **Array** | O(n) - grows with results | Small datasets (<10k items) | ❌ No |
### 📂 Directory Management ### 📂 Directory Management
SmartBucket provides powerful directory-like operations for organizing your files: SmartBucket provides powerful directory-like operations for organizing your files:
@@ -194,25 +420,41 @@ await subDir.fastPut({
const tree = await subDir.getTreeArray(); const tree = await subDir.getTreeArray();
console.log('🌳 Directory tree:', tree); console.log('🌳 Directory tree:', tree);
// Get directory path
console.log('📂 Base path:', subDir.getBasePath()); // "projects/2024/"
// Create empty file as placeholder // Create empty file as placeholder
await subDir.createEmptyFile('placeholder.txt'); await subDir.createEmptyFile('placeholder.txt');
``` ```
### 🌊 Streaming Operations ### 🌊 Streaming Operations
Handle large files efficiently with streaming: Handle large files efficiently with streaming support:
#### Download Streams #### Download Streams
```typescript ```typescript
// Node.js stream // Node.js stream (for file I/O, HTTP responses, etc.)
const nodeStream = await bucket.fastGetStream( const nodeStream = await bucket.fastGetStream(
{ path: 'large-video.mp4' }, { path: 'large-video.mp4' },
'nodestream' 'nodestream'
); );
// Pipe to file
import * as fs from 'node:fs';
nodeStream.pipe(fs.createWriteStream('local-video.mp4')); nodeStream.pipe(fs.createWriteStream('local-video.mp4'));
// Web stream (for modern environments) // Pipe to HTTP response
app.get('/download', async (req, res) => {
const stream = await bucket.fastGetStream(
{ path: 'file.pdf' },
'nodestream'
);
res.setHeader('Content-Type', 'application/pdf');
stream.pipe(res);
});
// Web stream (for modern browser/Deno environments)
const webStream = await bucket.fastGetStream( const webStream = await bucket.fastGetStream(
{ path: 'large-file.zip' }, { path: 'large-file.zip' },
'webstream' 'webstream'
@@ -222,6 +464,8 @@ const webStream = await bucket.fastGetStream(
#### Upload Streams #### Upload Streams
```typescript ```typescript
import * as fs from 'node:fs';
// Stream upload from file // Stream upload from file
const readStream = fs.createReadStream('big-data.csv'); const readStream = fs.createReadStream('big-data.csv');
await bucket.fastPutStream({ await bucket.fastPutStream({
@@ -235,6 +479,7 @@ await bucket.fastPutStream({
} }
} }
}); });
console.log('✅ Large file uploaded via stream');
``` ```
#### Reactive Streams with RxJS #### Reactive Streams with RxJS
@@ -246,19 +491,24 @@ const replaySubject = await bucket.fastGetReplaySubject({
chunkSize: 1024 chunkSize: 1024
}); });
// Multiple subscribers can consume the same data
replaySubject.subscribe({ replaySubject.subscribe({
next: (chunk) => processChunk(chunk), next: (chunk) => processChunk(chunk),
complete: () => console.log('✅ Stream complete') complete: () => console.log('✅ Stream complete')
}); });
replaySubject.subscribe({
next: (chunk) => logChunk(chunk)
});
``` ```
### 🔒 File Locking ### 🔒 File Locking
Prevent accidental modifications with file locking: Prevent concurrent modifications with built-in file locking:
```typescript ```typescript
const file = await bucket.getBaseDirectory() const file = await bucket.getBaseDirectory()
.getFileStrict({ path: 'important-config.json' }); .getFile({ path: 'important-config.json' });
// Lock file for 10 minutes // Lock file for 10 minutes
await file.lock({ timeoutMillis: 600000 }); await file.lock({ timeoutMillis: 600000 });
@@ -271,18 +521,28 @@ try {
console.log('❌ Cannot delete locked file'); console.log('❌ Cannot delete locked file');
} }
// Check lock status
const isLocked = await file.isLocked();
console.log(`Lock status: ${isLocked ? '🔒 Locked' : '🔓 Unlocked'}`);
// Unlock when done // Unlock when done
await file.unlock(); await file.unlock();
console.log('🔓 File unlocked'); console.log('🔓 File unlocked');
``` ```
**Lock use cases:**
- 🔄 Prevent concurrent writes during critical updates
- 🔐 Protect configuration files during deployment
- 🚦 Coordinate distributed workers
- 🛡️ Ensure data consistency
### 🏷️ Metadata Management ### 🏷️ Metadata Management
Attach and manage metadata for your files: Attach and manage rich metadata for your files:
```typescript ```typescript
const file = await bucket.getBaseDirectory() const file = await bucket.getBaseDirectory()
.getFileStrict({ path: 'document.pdf' }); .getFile({ path: 'document.pdf' });
// Get metadata handler // Get metadata handler
const metadata = await file.getMetaData(); const metadata = await file.getMetaData();
@@ -298,6 +558,11 @@ await metadata.setCustomMetaData({
value: 'Engineering' value: 'Engineering'
}); });
await metadata.setCustomMetaData({
key: 'version',
value: '1.0.0'
});
// Retrieve metadata // Retrieve metadata
const author = await metadata.getCustomMetaData({ key: 'author' }); const author = await metadata.getCustomMetaData({ key: 'author' });
console.log(`📝 Author: ${author}`); console.log(`📝 Author: ${author}`);
@@ -305,19 +570,35 @@ console.log(`📝 Author: ${author}`);
// Get all metadata // Get all metadata
const allMeta = await metadata.getAllCustomMetaData(); const allMeta = await metadata.getAllCustomMetaData();
console.log('📋 All metadata:', allMeta); console.log('📋 All metadata:', allMeta);
// { author: 'John Doe', department: 'Engineering', version: '1.0.0' }
// Check if metadata exists
const hasMetadata = await metadata.hasMetaData();
console.log(`Has metadata: ${hasMetadata ? '✅' : '❌'}`);
``` ```
**Metadata use cases:**
- 👤 Track file ownership and authorship
- 🏷️ Add tags and categories for search
- 📊 Store processing status or workflow state
- 🔍 Enable rich querying and filtering
- 📝 Maintain audit trails
### 🗑️ Trash & Recovery ### 🗑️ Trash & Recovery
SmartBucket includes an intelligent trash system for safe file deletion: SmartBucket includes an intelligent trash system for safe file deletion and recovery:
```typescript ```typescript
const file = await bucket.getBaseDirectory() const file = await bucket.getBaseDirectory()
.getFileStrict({ path: 'important-data.xlsx' }); .getFile({ path: 'important-data.xlsx' });
// Move to trash instead of permanent deletion // Move to trash instead of permanent deletion
await file.delete({ mode: 'trash' }); await file.delete({ mode: 'trash' });
console.log('🗑️ File moved to trash'); console.log('🗑️ File moved to trash (can be restored!)');
// Permanent deletion (use with caution!)
await file.delete({ mode: 'permanent' });
console.log('💀 File permanently deleted (cannot be recovered)');
// Access trash // Access trash
const trash = await bucket.getTrash(); const trash = await bucket.getTrash();
@@ -327,19 +608,33 @@ console.log(`📦 ${trashedFiles.length} files in trash`);
// Restore from trash // Restore from trash
const trashedFile = await bucket.getBaseDirectory() const trashedFile = await bucket.getBaseDirectory()
.getFileStrict({ .getFile({
path: 'important-data.xlsx', path: 'important-data.xlsx',
getFromTrash: true getFromTrash: true
}); });
await trashedFile.restore({ useOriginalPath: true }); await trashedFile.restore({ useOriginalPath: true });
console.log('♻️ File restored successfully'); console.log('♻️ File restored to original location');
// Permanent deletion from trash // Or restore to a different location
await trashedFile.restore({
useOriginalPath: false,
restorePath: 'recovered/important-data.xlsx'
});
console.log('♻️ File restored to new location');
// Empty trash permanently
await trash.emptyTrash(); await trash.emptyTrash();
console.log('🧹 Trash emptied'); console.log('🧹 Trash emptied');
``` ```
**Trash features:**
- ♻️ Recover accidentally deleted files
- 🏷️ Preserves original path in metadata
- ⏰ Tracks deletion timestamp
- 🔍 List and inspect trashed files
- 🧹 Bulk empty trash operation
### ⚡ Advanced Features ### ⚡ Advanced Features
#### File Statistics #### File Statistics
@@ -350,29 +645,39 @@ const stats = await bucket.fastStat({ path: 'document.pdf' });
console.log(`📊 Size: ${stats.size} bytes`); console.log(`📊 Size: ${stats.size} bytes`);
console.log(`📅 Last modified: ${stats.lastModified}`); console.log(`📅 Last modified: ${stats.lastModified}`);
console.log(`🏷️ ETag: ${stats.etag}`); console.log(`🏷️ ETag: ${stats.etag}`);
console.log(`🗂️ Storage class: ${stats.storageClass}`);
``` ```
#### Magic Bytes Detection #### Magic Bytes Detection
Detect file types by examining the first bytes (useful for validation):
```typescript ```typescript
// Read first bytes for file type detection // Read first bytes for file type detection
const magicBytes = await bucket.getMagicBytes({ const magicBytes = await bucket.getMagicBytes({
path: 'mystery-file', path: 'mystery-file',
length: 16 length: 16
}); });
console.log(`🔮 Magic bytes: ${magicBytes.toString('hex')}`);
// Or from a File object // Or from a File object
const file = await bucket.getBaseDirectory() const file = await bucket.getBaseDirectory()
.getFileStrict({ path: 'image.jpg' }); .getFile({ path: 'image.jpg' });
const magic = await file.getMagicBytes({ length: 4 }); const magic = await file.getMagicBytes({ length: 4 });
console.log(`🔮 Magic bytes: ${magic.toString('hex')}`);
// Check file signatures
if (magic[0] === 0xFF && magic[1] === 0xD8) {
console.log('📸 This is a JPEG image');
} else if (magic[0] === 0x89 && magic[1] === 0x50) {
console.log('🖼️ This is a PNG image');
}
``` ```
#### JSON Data Operations #### JSON Data Operations
```typescript ```typescript
const file = await bucket.getBaseDirectory() const file = await bucket.getBaseDirectory()
.getFileStrict({ path: 'config.json' }); .getFile({ path: 'config.json' });
// Read JSON data // Read JSON data
const config = await file.getJsonData(); const config = await file.getJsonData();
@@ -381,6 +686,8 @@ console.log('⚙️ Config loaded:', config);
// Update JSON data // Update JSON data
config.version = '2.0'; config.version = '2.0';
config.updated = new Date().toISOString(); config.updated = new Date().toISOString();
config.features.push('newFeature');
await file.writeJsonData(config); await file.writeJsonData(config);
console.log('💾 Config updated'); console.log('💾 Config updated');
``` ```
@@ -401,67 +708,157 @@ console.log(`Is file: ${isFile ? '📄' : '❌'}`);
#### Clean Bucket Contents #### Clean Bucket Contents
```typescript ```typescript
// Remove all files and directories (use with caution!) // Remove all files and directories (⚠️ use with caution!)
await bucket.cleanAllContents(); await bucket.cleanAllContents();
console.log('🧹 Bucket cleaned'); console.log('🧹 Bucket cleaned');
``` ```
### ☁️ Cloud Provider Support ### ☁️ Cloud Provider Support
SmartBucket works seamlessly with: SmartBucket works seamlessly with all major S3-compatible providers:
-**AWS S3** - Full compatibility with S3 API | Provider | Status | Notes |
-**Google Cloud Storage** - Via S3-compatible API |----------|--------|-------|
-**MinIO** - Self-hosted S3-compatible storage | **AWS S3** | ✅ Full support | Native S3 API |
-**DigitalOcean Spaces** - S3-compatible object storage | **MinIO** | ✅ Full support | Self-hosted, perfect for development |
-**Backblaze B2** - Cost-effective cloud storage | **DigitalOcean Spaces** | ✅ Full support | Cost-effective S3-compatible |
-**Wasabi** - High-performance S3-compatible storage | **Backblaze B2** | ✅ Full support | Very affordable storage |
-**Any S3-compatible provider** | **Wasabi** | ✅ Full support | High-performance hot storage |
| **Google Cloud Storage** | ✅ Full support | Via S3-compatible API |
| **Cloudflare R2** | ✅ Full support | Zero egress fees |
| **Any S3-compatible** | ✅ Full support | Works with any S3-compatible provider |
The library automatically handles provider quirks and optimizes operations for each platform while maintaining a consistent API. The library automatically handles provider quirks and optimizes operations for each platform while maintaining a consistent API.
**Configuration examples:**
```typescript
// AWS S3
const awsStorage = new SmartBucket({
accessKey: process.env.AWS_ACCESS_KEY_ID,
accessSecret: process.env.AWS_SECRET_ACCESS_KEY,
endpoint: 's3.amazonaws.com',
region: 'us-east-1',
useSsl: true
});
// MinIO (local development)
const minioStorage = new SmartBucket({
accessKey: 'minioadmin',
accessSecret: 'minioadmin',
endpoint: 'localhost',
port: 9000,
useSsl: false
});
// DigitalOcean Spaces
const doStorage = new SmartBucket({
accessKey: process.env.DO_SPACES_KEY,
accessSecret: process.env.DO_SPACES_SECRET,
endpoint: 'nyc3.digitaloceanspaces.com',
region: 'nyc3',
useSsl: true
});
// Backblaze B2
const b2Storage = new SmartBucket({
accessKey: process.env.B2_KEY_ID,
accessSecret: process.env.B2_APPLICATION_KEY,
endpoint: 's3.us-west-002.backblazeb2.com',
region: 'us-west-002',
useSsl: true
});
```
### 🔧 Advanced Configuration ### 🔧 Advanced Configuration
```typescript ```typescript
// Configure with custom options // Environment-based configuration with @push.rocks/qenv
const smartBucket = new SmartBucket({
accessKey: process.env.S3_ACCESS_KEY,
accessSecret: process.env.S3_SECRET_KEY,
endpoint: process.env.S3_ENDPOINT,
port: 443,
useSsl: true,
region: 'eu-central-1',
// Additional S3 client options can be passed through
});
// Environment-based configuration
import { Qenv } from '@push.rocks/qenv'; import { Qenv } from '@push.rocks/qenv';
const qenv = new Qenv('./', './.nogit/'); const qenv = new Qenv('./', './.nogit/');
const smartBucket = new SmartBucket({ const smartBucket = new SmartBucket({
accessKey: await qenv.getEnvVarOnDemandStrict('S3_ACCESS_KEY'), accessKey: await qenv.getEnvVarOnDemandStrict('S3_ACCESS_KEY'),
accessSecret: await qenv.getEnvVarOnDemandStrict('S3_SECRET'), accessSecret: await qenv.getEnvVarOnDemandStrict('S3_SECRET'),
endpoint: await qenv.getEnvVarOnDemandStrict('S3_ENDPOINT'), endpoint: await qenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
port: parseInt(await qenv.getEnvVarOnDemandStrict('S3_PORT')),
useSsl: await qenv.getEnvVarOnDemandStrict('S3_USE_SSL') === 'true',
region: await qenv.getEnvVarOnDemandStrict('S3_REGION')
}); });
``` ```
### 🧪 Testing ### 🧪 Testing
SmartBucket is thoroughly tested. Run tests with: SmartBucket is thoroughly tested with 82 comprehensive tests covering all features:
```bash ```bash
# Run all tests
pnpm test pnpm test
# Run specific test file
pnpm tstest test/test.listing.node+deno.ts --verbose
# Run tests with log file
pnpm test --logfile
``` ```
### 🤝 Best Practices ### 🛡️ Error Handling Best Practices
SmartBucket uses a **strict-by-default** approach - methods throw errors instead of returning null:
```typescript
// ✅ Good: Check existence first
if (await bucket.fastExists({ path: 'file.txt' })) {
const content = await bucket.fastGet({ path: 'file.txt' });
process(content);
}
// ✅ Good: Try/catch for expected failures
try {
const file = await bucket.fastGet({ path: 'might-not-exist.txt' });
process(file);
} catch (error) {
console.log('File not found, using default');
useDefault();
}
// ✅ Good: Explicit overwrite control
try {
await bucket.fastPut({
path: 'existing-file.txt',
contents: 'new data',
overwrite: false // Explicitly fail if exists
});
} catch (error) {
console.log('File already exists');
}
// ❌ Bad: Assuming file exists without checking
const content = await bucket.fastGet({ path: 'file.txt' }); // May throw!
```
### 💡 Best Practices
1. **Always use strict mode** for critical operations to catch errors early 1. **Always use strict mode** for critical operations to catch errors early
2. **Implement proper error handling** for network and permission issues 2. **Check existence first** with `fastExists()`, `bucketExists()`, etc. before operations
3. **Use streaming** for large files to optimize memory usage 3. **Implement proper error handling** for network and permission issues
4. **Leverage metadata** for organizing and searching files 4. **Use streaming** for large files (>100MB) to optimize memory usage
5. **Enable trash mode** for important data to prevent accidental loss 5. **Leverage metadata** for organizing and searching files
6. **Lock files** during critical operations to prevent race conditions 6. **Enable trash mode** for important data to prevent accidental loss
7. **Clean up resources** properly when done 7. **Lock files** during critical operations to prevent race conditions
8. **Use async generators** for listing large buckets to avoid memory issues
9. **Set explicit overwrite flags** to prevent accidental file overwrites
10. **Clean up resources** properly when done
### 📊 Performance Tips
- **Listing**: Use async generators or cursors for buckets with >10,000 objects
- **Uploads**: Use streams for files >100MB
- **Downloads**: Use streams for files you'll process incrementally
- **Metadata**: Cache metadata when reading frequently
- **Locking**: Keep lock durations as short as possible
- **Glob patterns**: Be specific to reduce objects scanned
## License and Legal Information ## License and Legal Information

View File

@@ -0,0 +1,298 @@
// test.listing.node+deno.ts - Tests for memory-efficient listing methods
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartbucket from '../ts/index.js';
// Get test configuration
import * as qenv from '@push.rocks/qenv';
const testQenv = new qenv.Qenv('./', './.nogit/');
// Test bucket reference
let testBucket: smartbucket.Bucket;
let testSmartbucket: smartbucket.SmartBucket;
// Setup: Create test bucket and populate with test data
tap.test('should create valid smartbucket and bucket', async () => {
testSmartbucket = new smartbucket.SmartBucket({
accessKey: await testQenv.getEnvVarOnDemand('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemand('S3_SECRETKEY'),
endpoint: await testQenv.getEnvVarOnDemand('S3_ENDPOINT'),
port: parseInt(await testQenv.getEnvVarOnDemand('S3_PORT')),
useSsl: false,
});
testBucket = await smartbucket.Bucket.getBucketByName(
testSmartbucket,
await testQenv.getEnvVarOnDemand('S3_BUCKET')
);
expect(testBucket).toBeInstanceOf(smartbucket.Bucket);
});
tap.test('should clean bucket and create test data for listing tests', async () => {
// Clean bucket first
await testBucket.cleanAllContents();
// Create test structure:
// npm/packages/foo/index.json
// npm/packages/foo/1.0.0.tgz
// npm/packages/bar/index.json
// npm/packages/bar/2.0.0.tgz
// oci/blobs/sha256-abc.tar
// oci/blobs/sha256-def.tar
// oci/manifests/latest.json
// docs/readme.md
// docs/api.md
const testFiles = [
'npm/packages/foo/index.json',
'npm/packages/foo/1.0.0.tgz',
'npm/packages/bar/index.json',
'npm/packages/bar/2.0.0.tgz',
'oci/blobs/sha256-abc.tar',
'oci/blobs/sha256-def.tar',
'oci/manifests/latest.json',
'docs/readme.md',
'docs/api.md',
];
for (const filePath of testFiles) {
await testBucket.fastPut({
path: filePath,
contents: `test content for ${filePath}`,
});
}
});
// ==========================
// Async Generator Tests
// ==========================
tap.test('listAllObjects should iterate all objects with prefix', async () => {
const keys: string[] = [];
for await (const key of testBucket.listAllObjects('npm/')) {
keys.push(key);
}
expect(keys.length).toEqual(4);
expect(keys).toContain('npm/packages/foo/index.json');
expect(keys).toContain('npm/packages/bar/2.0.0.tgz');
});
tap.test('listAllObjects should support early termination', async () => {
let count = 0;
for await (const key of testBucket.listAllObjects('')) {
count++;
if (count >= 3) break; // Early exit
}
expect(count).toEqual(3);
});
tap.test('listAllObjects without prefix should list all objects', async () => {
const keys: string[] = [];
for await (const key of testBucket.listAllObjects()) {
keys.push(key);
}
expect(keys.length).toBeGreaterThanOrEqual(9);
});
// ==========================
// Observable Tests
// ==========================
tap.test('listAllObjectsObservable should emit all objects', async () => {
const keys: string[] = [];
await new Promise<void>((resolve, reject) => {
testBucket.listAllObjectsObservable('oci/')
.subscribe({
next: (key) => keys.push(key),
error: (err) => reject(err),
complete: () => resolve(),
});
});
expect(keys.length).toEqual(3);
expect(keys).toContain('oci/blobs/sha256-abc.tar');
expect(keys).toContain('oci/manifests/latest.json');
});
tap.test('listAllObjectsObservable should support RxJS operators', async () => {
const jsonFiles: string[] = [];
await new Promise<void>((resolve, reject) => {
testBucket.listAllObjectsObservable('npm/')
.subscribe({
next: (key: string) => {
if (key.endsWith('.json')) {
jsonFiles.push(key);
}
},
error: (err: any) => reject(err),
complete: () => resolve(),
});
});
expect(jsonFiles.length).toEqual(2);
expect(jsonFiles.every((k) => k.endsWith('.json'))).toBeTrue();
});
// ==========================
// Cursor Tests
// ==========================
tap.test('createCursor should allow manual pagination', async () => {
const cursor = testBucket.createCursor('npm/', { pageSize: 2 });
// First page
const page1 = await cursor.next();
expect(page1.keys.length).toEqual(2);
expect(page1.done).toBeFalse();
// Second page
const page2 = await cursor.next();
expect(page2.keys.length).toEqual(2);
expect(page2.done).toBeTrue();
});
tap.test('cursor.hasMore() should accurately track state', async () => {
const cursor = testBucket.createCursor('docs/', { pageSize: 10 });
expect(cursor.hasMore()).toBeTrue();
await cursor.next(); // Should get all docs files
expect(cursor.hasMore()).toBeFalse();
});
tap.test('cursor.reset() should allow re-iteration', async () => {
const cursor = testBucket.createCursor('docs/');
const firstRun = await cursor.next();
expect(firstRun.keys.length).toBeGreaterThan(0);
cursor.reset();
expect(cursor.hasMore()).toBeTrue();
const secondRun = await cursor.next();
expect(secondRun.keys).toEqual(firstRun.keys);
});
tap.test('cursor should support save/restore with token', async () => {
const cursor1 = testBucket.createCursor('npm/', { pageSize: 2 });
await cursor1.next(); // Advance cursor
const token = cursor1.getToken();
expect(token).toBeDefined();
// Create new cursor and restore state
const cursor2 = testBucket.createCursor('npm/', { pageSize: 2 });
cursor2.setToken(token);
const page = await cursor2.next();
expect(page.keys.length).toBeGreaterThan(0);
});
// ==========================
// findByGlob Tests
// ==========================
tap.test('findByGlob should match simple patterns', async () => {
const matches: string[] = [];
for await (const key of testBucket.findByGlob('**/*.json')) {
matches.push(key);
}
expect(matches.length).toEqual(3); // foo/index.json, bar/index.json, latest.json
expect(matches.every((k) => k.endsWith('.json'))).toBeTrue();
});
tap.test('findByGlob should match specific path patterns', async () => {
const matches: string[] = [];
for await (const key of testBucket.findByGlob('npm/packages/*/index.json')) {
matches.push(key);
}
expect(matches.length).toEqual(2);
expect(matches).toContain('npm/packages/foo/index.json');
expect(matches).toContain('npm/packages/bar/index.json');
});
tap.test('findByGlob should match wildcard patterns', async () => {
const matches: string[] = [];
for await (const key of testBucket.findByGlob('oci/blobs/*')) {
matches.push(key);
}
expect(matches.length).toEqual(2);
expect(matches.every((k) => k.startsWith('oci/blobs/'))).toBeTrue();
});
// ==========================
// listAllObjectsArray Tests
// ==========================
tap.test('listAllObjectsArray should collect all keys into array', async () => {
const keys = await testBucket.listAllObjectsArray('docs/');
expect(Array.isArray(keys)).toBeTrue();
expect(keys.length).toEqual(2);
expect(keys).toContain('docs/readme.md');
expect(keys).toContain('docs/api.md');
});
tap.test('listAllObjectsArray without prefix should return all objects', async () => {
const keys = await testBucket.listAllObjectsArray();
expect(keys.length).toBeGreaterThanOrEqual(9);
});
// ==========================
// Performance/Edge Case Tests
// ==========================
tap.test('should handle empty prefix results gracefully', async () => {
const keys: string[] = [];
for await (const key of testBucket.listAllObjects('nonexistent/')) {
keys.push(key);
}
expect(keys.length).toEqual(0);
});
tap.test('cursor should handle empty results', async () => {
const cursor = testBucket.createCursor('nonexistent/');
const result = await cursor.next();
expect(result.keys.length).toEqual(0);
expect(result.done).toBeTrue();
expect(cursor.hasMore()).toBeFalse();
});
tap.test('observable should complete immediately on empty results', async () => {
let completed = false;
let count = 0;
await new Promise<void>((resolve, reject) => {
testBucket.listAllObjectsObservable('nonexistent/')
.subscribe({
next: () => count++,
error: (err) => reject(err),
complete: () => {
completed = true;
resolve();
},
});
});
expect(count).toEqual(0);
expect(completed).toBeTrue();
});
// Cleanup
tap.test('should clean up test data', async () => {
await testBucket.cleanAllContents();
});
export default tap.start();

View File

@@ -0,0 +1,76 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as smartbucket from '../ts/index.js';
class FakeS3Client {
private callIndex = 0;
constructor(private readonly pages: Array<Partial<plugins.s3.ListObjectsV2Output>>) {}
public async send(_command: any) {
const page = this.pages[this.callIndex] || { Contents: [], CommonPrefixes: [], IsTruncated: false };
this.callIndex += 1;
return page;
}
}
tap.test('MetaData.hasMetaData should return false when metadata file does not exist', async () => {
const fakeFile = {
name: 'file.txt',
parentDirectoryRef: {
async getFile() {
throw new Error(`File not found at path 'file.txt.metadata'`);
},
},
} as unknown as smartbucket.File;
const hasMetaData = await smartbucket.MetaData.hasMetaData({ file: fakeFile });
expect(hasMetaData).toBeFalse();
});
tap.test('getSubDirectoryByName should create correct parent chain for new nested directories', async () => {
const fakeSmartbucket = { s3Client: new FakeS3Client([{ Contents: [], CommonPrefixes: [] }]) } as unknown as smartbucket.SmartBucket;
const bucket = new smartbucket.Bucket(fakeSmartbucket, 'test-bucket');
const baseDirectory = new smartbucket.Directory(bucket, null as any, '');
const nestedDirectory = await baseDirectory.getSubDirectoryByName('level1/level2', { getEmptyDirectory: true });
expect(nestedDirectory.name).toEqual('level2');
expect(nestedDirectory.parentDirectoryRef.name).toEqual('level1');
expect(nestedDirectory.getBasePath()).toEqual('level1/level2/');
});
tap.test('listFiles should aggregate results across paginated ListObjectsV2 responses', async () => {
const firstPage = {
Contents: Array.from({ length: 1000 }, (_, index) => ({ Key: `file-${index}` })),
IsTruncated: true,
NextContinuationToken: 'token-1',
};
const secondPage = {
Contents: Array.from({ length: 200 }, (_, index) => ({ Key: `file-${1000 + index}` })),
IsTruncated: false,
};
const fakeSmartbucket = { s3Client: new FakeS3Client([firstPage, secondPage]) } as unknown as smartbucket.SmartBucket;
const bucket = new smartbucket.Bucket(fakeSmartbucket, 'test-bucket');
const baseDirectory = new smartbucket.Directory(bucket, null as any, '');
const files = await baseDirectory.listFiles();
expect(files.length).toEqual(1200);
});
tap.test('listDirectories should aggregate CommonPrefixes across pagination', async () => {
const fakeSmartbucket = {
s3Client: new FakeS3Client([
{ CommonPrefixes: [{ Prefix: 'dirA/' }], IsTruncated: true, NextContinuationToken: 'token-1' },
{ CommonPrefixes: [{ Prefix: 'dirB/' }], IsTruncated: false },
]),
} as unknown as smartbucket.SmartBucket;
const bucket = new smartbucket.Bucket(fakeSmartbucket, 'test-bucket');
const baseDirectory = new smartbucket.Directory(bucket, null as any, '');
const directories = await baseDirectory.listDirectories();
expect(directories.map((d) => d.name)).toEqual(['dirA', 'dirB']);
});
export default tap.start();

View File

@@ -12,13 +12,16 @@ 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.getEnvVarOnDemandStrict('S3_ACCESSKEY'), accessKey: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSSECRET'), accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_SECRETKEY'),
endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'), endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
port: parseInt(await testQenv.getEnvVarOnDemandStrict('S3_PORT')),
useSsl: false,
}); });
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket); expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByName(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),); const bucketName = await testQenv.getEnvVarOnDemandStrict('S3_BUCKET');
myBucket = await testSmartbucket.getBucketByName(bucketName);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket); expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('test-pushrocks-smartbucket'); expect(myBucket.name).toEqual(bucketName);
}); });
tap.test('should clean all contents', async () => { tap.test('should clean all contents', async () => {

View File

@@ -13,13 +13,15 @@ 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.getEnvVarOnDemandStrict('S3_ACCESSKEY'), accessKey: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSKEY'),
accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_ACCESSSECRET'), accessSecret: await testQenv.getEnvVarOnDemandStrict('S3_SECRETKEY'),
endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'), endpoint: await testQenv.getEnvVarOnDemandStrict('S3_ENDPOINT'),
port: parseInt(await testQenv.getEnvVarOnDemandStrict('S3_PORT')),
useSsl: false,
}); });
expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket); expect(testSmartbucket).toBeInstanceOf(smartbucket.SmartBucket);
myBucket = await testSmartbucket.getBucketByName(await testQenv.getEnvVarOnDemandStrict('S3_BUCKET'),); const bucketName = await testQenv.getEnvVarOnDemandStrict('S3_BUCKET');
myBucket = await testSmartbucket.getBucketByName(bucketName);
expect(myBucket).toBeInstanceOf(smartbucket.Bucket); expect(myBucket).toBeInstanceOf(smartbucket.Bucket);
expect(myBucket.name).toEqual('test-pushrocks-smartbucket');
}); });
tap.test('should clean all contents', async () => { tap.test('should clean all contents', async () => {

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartbucket', name: '@push.rocks/smartbucket',
version: '4.0.1', version: '4.3.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.' 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

@@ -7,6 +7,7 @@ import { SmartBucket } from './classes.smartbucket.js';
import { Directory } from './classes.directory.js'; import { Directory } from './classes.directory.js';
import { File } from './classes.file.js'; import { File } from './classes.file.js';
import { Trash } from './classes.trash.js'; import { Trash } from './classes.trash.js';
import { ListCursor, type IListCursorOptions } from './classes.listcursor.js';
/** /**
* The bucket class exposes the basic functionality of a bucket. * The bucket class exposes the basic functionality of a bucket.
@@ -469,6 +470,145 @@ export class Bucket {
} }
} }
// ==========================================
// Memory-Efficient Listing Methods (Phase 1)
// ==========================================
/**
* List all objects with a given prefix using async generator (memory-efficient streaming)
* @param prefix - Optional prefix to filter objects (default: '' for all objects)
* @yields Object keys one at a time
* @example
* ```ts
* for await (const key of bucket.listAllObjects('npm/')) {
* console.log(key);
* if (shouldStop) break; // Early exit supported
* }
* ```
*/
public async *listAllObjects(prefix: string = ''): AsyncIterableIterator<string> {
let continuationToken: string | undefined;
do {
const command = new plugins.s3.ListObjectsV2Command({
Bucket: this.name,
Prefix: prefix,
ContinuationToken: continuationToken,
});
const response = await this.smartbucketRef.s3Client.send(command);
for (const obj of response.Contents || []) {
if (obj.Key) yield obj.Key;
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
}
/**
* List all objects as an RxJS Observable (for complex reactive pipelines)
* @param prefix - Optional prefix to filter objects (default: '' for all objects)
* @returns Observable that emits object keys
* @example
* ```ts
* bucket.listAllObjectsObservable('npm/')
* .pipe(
* filter(key => key.endsWith('.json')),
* take(100)
* )
* .subscribe(key => console.log(key));
* ```
*/
public listAllObjectsObservable(prefix: string = ''): plugins.smartrx.rxjs.Observable<string> {
return new plugins.smartrx.rxjs.Observable<string>((subscriber) => {
const fetchPage = async (token?: string) => {
try {
const command = new plugins.s3.ListObjectsV2Command({
Bucket: this.name,
Prefix: prefix,
ContinuationToken: token,
});
const response = await this.smartbucketRef.s3Client.send(command);
for (const obj of response.Contents || []) {
if (obj.Key) subscriber.next(obj.Key);
}
if (response.NextContinuationToken) {
await fetchPage(response.NextContinuationToken);
} else {
subscriber.complete();
}
} catch (error) {
subscriber.error(error);
}
};
fetchPage();
});
}
/**
* Create a cursor for manual pagination control
* @param prefix - Optional prefix to filter objects (default: '' for all objects)
* @param options - Cursor options (pageSize, etc.)
* @returns ListCursor instance
* @example
* ```ts
* const cursor = bucket.createCursor('npm/', { pageSize: 500 });
* while (cursor.hasMore()) {
* const { keys, done } = await cursor.next();
* console.log(`Processing ${keys.length} keys...`);
* }
* ```
*/
public createCursor(prefix: string = '', options?: IListCursorOptions): ListCursor {
return new ListCursor(this, prefix, options);
}
// ==========================================
// High-Level Listing Helpers (Phase 2)
// ==========================================
/**
* Find objects matching a glob pattern (memory-efficient)
* @param pattern - Glob pattern (e.g., "**\/*.json", "npm/packages/*\/index.json")
* @yields Matching object keys
* @example
* ```ts
* for await (const key of bucket.findByGlob('npm/packages/*\/index.json')) {
* console.log('Found package index:', key);
* }
* ```
*/
public async *findByGlob(pattern: string): AsyncIterableIterator<string> {
const matcher = new plugins.Minimatch(pattern);
for await (const key of this.listAllObjects('')) {
if (matcher.match(key)) yield key;
}
}
/**
* List all objects and collect into an array (convenience method)
* WARNING: Loads entire result set into memory. Use listAllObjects() generator for large buckets.
* @param prefix - Optional prefix to filter objects (default: '' for all objects)
* @returns Array of all object keys
* @example
* ```ts
* const allKeys = await bucket.listAllObjectsArray('npm/');
* console.log(`Found ${allKeys.length} objects`);
* ```
*/
public async listAllObjectsArray(prefix: string = ''): Promise<string[]> {
const keys: string[] = [];
for await (const key of this.listAllObjects(prefix)) {
keys.push(key);
}
return keys;
}
public async cleanAllContents(): Promise<void> { public async cleanAllContents(): Promise<void> {
try { try {
// Define the command type explicitly // Define the command type explicitly

View File

@@ -120,19 +120,44 @@ export class Directory {
return directories.some(dir => dir.name === dirNameArg); return directories.some(dir => dir.name === dirNameArg);
} }
/**
* Collects all ListObjectsV2 pages for a prefix.
*/
private async listObjectsV2AllPages(prefix: string, delimiter?: string) {
const allContents: plugins.s3._Object[] = [];
const allCommonPrefixes: plugins.s3.CommonPrefix[] = [];
let continuationToken: string | undefined;
do {
const command = new plugins.s3.ListObjectsV2Command({
Bucket: this.bucketRef.name,
Prefix: prefix,
Delimiter: delimiter,
ContinuationToken: continuationToken,
});
const response = await this.bucketRef.smartbucketRef.s3Client.send(command);
if (response.Contents) {
allContents.push(...response.Contents);
}
if (response.CommonPrefixes) {
allCommonPrefixes.push(...response.CommonPrefixes);
}
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
} while (continuationToken);
return { contents: allContents, commonPrefixes: allCommonPrefixes };
}
/** /**
* lists all files * lists all files
*/ */
public async listFiles(): Promise<File[]> { public async listFiles(): Promise<File[]> {
const command = new plugins.s3.ListObjectsV2Command({ const { contents } = await this.listObjectsV2AllPages(this.getBasePath(), '/');
Bucket: this.bucketRef.name,
Prefix: this.getBasePath(),
Delimiter: '/',
});
const response = await this.bucketRef.smartbucketRef.s3Client.send(command);
const fileArray: File[] = []; const fileArray: File[] = [];
response.Contents?.forEach((item) => { 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('/')) {
@@ -154,16 +179,11 @@ export class Directory {
*/ */
public async listDirectories(): Promise<Directory[]> { public async listDirectories(): Promise<Directory[]> {
try { try {
const command = new plugins.s3.ListObjectsV2Command({ const { commonPrefixes } = await this.listObjectsV2AllPages(this.getBasePath(), '/');
Bucket: this.bucketRef.name,
Prefix: this.getBasePath(),
Delimiter: '/',
});
const response = await this.bucketRef.smartbucketRef.s3Client.send(command);
const directoryArray: Directory[] = []; const directoryArray: Directory[] = [];
if (response.CommonPrefixes) { if (commonPrefixes) {
response.CommonPrefixes.forEach((item) => { commonPrefixes.forEach((item) => {
if (item.Prefix) { if (item.Prefix) {
const subtractedPath = item.Prefix.replace(this.getBasePath(), ''); const subtractedPath = item.Prefix.replace(this.getBasePath(), '');
if (subtractedPath.endsWith('/')) { if (subtractedPath.endsWith('/')) {
@@ -235,7 +255,7 @@ export class Directory {
return returnDirectory; return returnDirectory;
} }
if (optionsArg.getEmptyDirectory || optionsArg.createWithInitializerFile) { if (optionsArg.getEmptyDirectory || optionsArg.createWithInitializerFile) {
returnDirectory = new Directory(this.bucketRef, this, dirNameToSearch); returnDirectory = new Directory(this.bucketRef, directoryArg, dirNameToSearch);
} }
if (isFinalDirectory && optionsArg.createWithInitializerFile) { if (isFinalDirectory && optionsArg.createWithInitializerFile) {
returnDirectory?.createEmptyFile('00init.txt'); returnDirectory?.createEmptyFile('00init.txt');

89
ts/classes.listcursor.ts Normal file
View File

@@ -0,0 +1,89 @@
// classes.listcursor.ts
import * as plugins from './plugins.js';
import type { Bucket } from './classes.bucket.js';
export interface IListCursorOptions {
pageSize?: number;
}
export interface IListCursorResult {
keys: string[];
done: boolean;
}
/**
* ListCursor provides explicit pagination control for listing objects in a bucket.
* Useful for UI pagination, resumable operations, and manual batch processing.
*/
export class ListCursor {
private continuationToken?: string;
private exhausted = false;
private pageSize: number;
constructor(
private bucket: Bucket,
private prefix: string,
options: IListCursorOptions = {}
) {
this.pageSize = options.pageSize || 1000;
}
/**
* Fetch the next page of object keys
* @returns Object with keys array and done flag
*/
public async next(): Promise<IListCursorResult> {
if (this.exhausted) {
return { keys: [], done: true };
}
const command = new plugins.s3.ListObjectsV2Command({
Bucket: this.bucket.name,
Prefix: this.prefix,
MaxKeys: this.pageSize,
ContinuationToken: this.continuationToken,
});
const response = await this.bucket.smartbucketRef.s3Client.send(command);
const keys = (response.Contents || [])
.map((obj) => obj.Key)
.filter((key): key is string => !!key);
this.continuationToken = response.NextContinuationToken;
this.exhausted = !this.continuationToken;
return { keys, done: this.exhausted };
}
/**
* Check if there are more pages to fetch
*/
public hasMore(): boolean {
return !this.exhausted;
}
/**
* Reset the cursor to start from the beginning
*/
public reset(): void {
this.continuationToken = undefined;
this.exhausted = false;
}
/**
* Get the current continuation token (for saving/restoring state)
*/
public getToken(): string | undefined {
return this.continuationToken;
}
/**
* Set the continuation token (for resuming from a saved state)
*/
public setToken(token: string | undefined): void {
this.continuationToken = token;
this.exhausted = !token;
}
}

View File

@@ -4,11 +4,23 @@ import { File } from './classes.file.js';
export class MetaData { export class MetaData {
public static async hasMetaData(optionsArg: { file: File }) { public static async hasMetaData(optionsArg: { file: File }) {
// lets find the existing metadata file // try finding the existing metadata file; return false if it doesn't exist
try {
const existingFile = await optionsArg.file.parentDirectoryRef.getFile({ const existingFile = await optionsArg.file.parentDirectoryRef.getFile({
path: optionsArg.file.name + '.metadata', path: optionsArg.file.name + '.metadata',
}); });
return !!existingFile; return !!existingFile;
} catch (error: any) {
const message = error?.message || '';
const isNotFound =
message.includes('File not found') ||
error?.name === 'NotFound' ||
error?.$metadata?.httpStatusCode === 404;
if (isNotFound) {
return false;
}
throw error;
}
} }
// static // static

View File

@@ -2,3 +2,6 @@ export * from './classes.smartbucket.js';
export * from './classes.bucket.js'; export * from './classes.bucket.js';
export * from './classes.directory.js'; export * from './classes.directory.js';
export * from './classes.file.js'; export * from './classes.file.js';
export * from './classes.listcursor.js';
export * from './classes.metadata.js';
export * from './classes.trash.js';

View File

@@ -26,7 +26,9 @@ export {
// third party scope // third party scope
import * as s3 from '@aws-sdk/client-s3'; import * as s3 from '@aws-sdk/client-s3';
import { Minimatch } from 'minimatch';
export { export {
s3, s3,
Minimatch,
} }