@objectstack/service-storage 4.0.4 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # @objectstack/service-storage
2
2
 
3
- Storage Service for ObjectStack — implements `IStorageService` with local filesystem and S3 adapter skeleton.
3
+ Storage Service for ObjectStack — implements `IStorageService` with local filesystem and S3-compatible adapters, REST routes for front-end uploads, and presigned URL support.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Multiple Adapters**: Local filesystem (development) and S3-compatible storage (production)
8
- - **File Upload**: Upload files with automatic path management
9
- - **File Download**: Retrieve files with streaming support
10
- - **URL Generation**: Generate signed URLs for secure access
11
- - **Metadata**: Store and retrieve file metadata
12
- - **Directory Operations**: Create, list, and delete directories
13
- - **Multipart Upload**: Support for large file uploads
14
- - **Type-Safe**: Full TypeScript support
8
+ - **Presigned Uploads**: Browser-direct upload via presigned URLs (S3 native, local HMAC-signed tokens)
9
+ - **Chunked / Multipart Upload**: Resumable large file uploads with progress tracking
10
+ - **File Metadata Store**: `system_file` object tracks fileId key mapping and lifecycle status
11
+ - **REST Routes**: Auto-mounted `/api/v1/storage/*` endpoints consumed by `@objectstack/client`
12
+ - **Type-Safe**: Full TypeScript support with Zod-validated API contracts
15
13
 
16
14
  ## Installation
17
15
 
@@ -19,7 +17,7 @@ Storage Service for ObjectStack — implements `IStorageService` with local file
19
17
  pnpm add @objectstack/service-storage
20
18
  ```
21
19
 
22
- For S3 adapter:
20
+ For S3 adapter (optional peer dependencies):
23
21
  ```bash
24
22
  pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
25
23
  ```
@@ -27,17 +25,19 @@ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
27
25
  ## Basic Usage
28
26
 
29
27
  ```typescript
30
- import { defineStack } from '@objectstack/spec';
31
- import { ServiceStorage } from '@objectstack/service-storage';
28
+ import { ObjectKernel } from '@objectstack/core';
29
+ import { StorageServicePlugin } from '@objectstack/service-storage';
32
30
 
33
- const stack = defineStack({
34
- services: [
35
- ServiceStorage.configure({
36
- adapter: 'local', // or 's3'
37
- basePath: './uploads',
38
- }),
39
- ],
40
- });
31
+ const kernel = new ObjectKernel();
32
+ kernel.use(new StorageServicePlugin({
33
+ adapter: 'local',
34
+ local: { rootDir: './uploads' },
35
+ }));
36
+ await kernel.bootstrap();
37
+
38
+ // Programmatic access
39
+ const storage = kernel.getService('file-storage');
40
+ await storage.upload('files/hello.txt', Buffer.from('hello'));
41
41
  ```
42
42
 
43
43
  ## Configuration
@@ -45,422 +45,97 @@ const stack = defineStack({
45
45
  ### Local Filesystem Adapter (Development)
46
46
 
47
47
  ```typescript
48
- ServiceStorage.configure({
48
+ new StorageServicePlugin({
49
49
  adapter: 'local',
50
- basePath: './uploads',
51
- baseUrl: 'http://localhost:3000/uploads',
50
+ local: {
51
+ rootDir: './uploads',
52
+ baseUrl: 'http://localhost:3000', // for presigned URLs
53
+ signingSecret: 'dev-secret', // auto-generated if omitted
54
+ },
55
+ presignedTtl: 3600, // 1 hour
56
+ sessionTtl: 86400, // 24 hours for chunked uploads
52
57
  });
53
58
  ```
54
59
 
55
60
  ### S3 Adapter (Production)
56
61
 
57
62
  ```typescript
58
- ServiceStorage.configure({
63
+ new StorageServicePlugin({
59
64
  adapter: 's3',
60
65
  s3: {
61
66
  bucket: 'my-bucket',
62
67
  region: 'us-east-1',
63
- credentials: {
64
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
65
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
66
- },
67
- },
68
- baseUrl: 'https://my-bucket.s3.amazonaws.com',
69
- });
70
- ```
71
-
72
- ### S3-Compatible Services (Cloudflare R2, DigitalOcean Spaces, MinIO)
73
-
74
- ```typescript
75
- ServiceStorage.configure({
76
- adapter: 's3',
77
- s3: {
78
- bucket: 'my-bucket',
79
- region: 'auto',
80
- endpoint: 'https://r2.cloudflarestorage.com/account-id',
81
- credentials: {
82
- accessKeyId: process.env.R2_ACCESS_KEY_ID,
83
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
84
- },
85
- },
86
- });
87
- ```
88
-
89
- ## Service API
90
-
91
- ```typescript
92
- // Get storage service
93
- const storage = kernel.getService<IStorageService>('storage');
94
- ```
95
-
96
- ### File Upload
97
-
98
- ```typescript
99
- // Upload a file from buffer
100
- await storage.upload({
101
- path: 'documents/contract.pdf',
102
- data: fileBuffer,
103
- contentType: 'application/pdf',
104
- metadata: {
105
- userId: 'user:123',
106
- category: 'contracts',
107
- },
108
- });
109
-
110
- // Upload from stream
111
- await storage.uploadStream({
112
- path: 'videos/demo.mp4',
113
- stream: fileStream,
114
- contentType: 'video/mp4',
115
- });
116
-
117
- // Upload with automatic path generation
118
- const path = await storage.uploadAuto({
119
- data: fileBuffer,
120
- fileName: 'profile.jpg',
121
- folder: 'avatars',
122
- contentType: 'image/jpeg',
123
- });
124
- // Returns: 'avatars/2024/01/15/abc123-profile.jpg'
125
- ```
126
-
127
- ### File Download
128
-
129
- ```typescript
130
- // Download file as buffer
131
- const file = await storage.download('documents/contract.pdf');
132
- console.log(file.data); // Buffer
133
- console.log(file.contentType); // 'application/pdf'
134
- console.log(file.size); // File size in bytes
135
-
136
- // Download as stream
137
- const stream = await storage.downloadStream('videos/demo.mp4');
138
- stream.pipe(res); // Pipe to HTTP response
139
- ```
140
-
141
- ### File Management
142
-
143
- ```typescript
144
- // Check if file exists
145
- const exists = await storage.exists('documents/contract.pdf');
146
-
147
- // Get file metadata
148
- const metadata = await storage.getMetadata('documents/contract.pdf');
149
- // {
150
- // size: 1024000,
151
- // contentType: 'application/pdf',
152
- // lastModified: Date,
153
- // metadata: { userId: 'user:123', category: 'contracts' }
154
- // }
155
-
156
- // Delete file
157
- await storage.delete('documents/contract.pdf');
158
-
159
- // Copy file
160
- await storage.copy({
161
- from: 'documents/contract.pdf',
162
- to: 'archive/2024/contract.pdf',
163
- });
164
-
165
- // Move file
166
- await storage.move({
167
- from: 'temp/upload.pdf',
168
- to: 'documents/contract.pdf',
169
- });
170
- ```
171
-
172
- ### Directory Operations
173
-
174
- ```typescript
175
- // List files in directory
176
- const files = await storage.list('documents', {
177
- recursive: false,
178
- limit: 100,
179
- });
180
- // Returns: ['contract.pdf', 'invoice.pdf', 'report.docx']
181
-
182
- // List with metadata
183
- const files = await storage.listDetailed('documents');
184
- // Returns: [
185
- // { path: 'contract.pdf', size: 1024000, lastModified: Date },
186
- // { path: 'invoice.pdf', size: 512000, lastModified: Date },
187
- // ]
188
-
189
- // Delete directory and all contents
190
- await storage.deleteDirectory('temp');
191
- ```
192
-
193
- ### URL Generation
194
-
195
- ```typescript
196
- // Generate public URL (for public files)
197
- const url = storage.getUrl('public/logo.png');
198
- // 'https://my-bucket.s3.amazonaws.com/public/logo.png'
199
-
200
- // Generate signed URL (for private files, expires in 1 hour)
201
- const signedUrl = await storage.getSignedUrl('documents/contract.pdf', {
202
- expiresIn: 3600,
203
- operation: 'read', // or 'write'
204
- });
205
- // 'https://my-bucket.s3.amazonaws.com/documents/contract.pdf?X-Amz-Signature=...'
206
-
207
- // Generate upload URL (for direct client uploads)
208
- const uploadUrl = await storage.getUploadUrl('uploads/temp.pdf', {
209
- expiresIn: 900, // 15 minutes
210
- contentType: 'application/pdf',
211
- maxSize: 10485760, // 10MB
212
- });
213
- ```
214
-
215
- ## Advanced Features
216
-
217
- ### Multipart Upload (Large Files)
218
-
219
- ```typescript
220
- // Initialize multipart upload
221
- const uploadId = await storage.initMultipartUpload({
222
- path: 'large-files/video.mp4',
223
- contentType: 'video/mp4',
224
- });
225
-
226
- // Upload parts (can be done in parallel)
227
- const parts = [];
228
- for (let i = 0; i < chunks.length; i++) {
229
- const part = await storage.uploadPart({
230
- uploadId,
231
- partNumber: i + 1,
232
- data: chunks[i],
233
- });
234
- parts.push(part);
235
- }
236
-
237
- // Complete multipart upload
238
- await storage.completeMultipartUpload({
239
- uploadId,
240
- parts,
241
- });
242
-
243
- // Or abort if failed
244
- await storage.abortMultipartUpload(uploadId);
245
- ```
246
-
247
- ### Direct Browser Upload
248
-
249
- ```typescript
250
- // Server: Generate presigned POST URL
251
- const presignedPost = await storage.getPresignedPost({
252
- path: 'uploads/${filename}',
253
- conditions: [
254
- ['content-length-range', 0, 10485760], // Max 10MB
255
- ['starts-with', '$Content-Type', 'image/'], // Only images
256
- ],
257
- expiresIn: 900, // 15 minutes
258
- });
259
-
260
- // Client: Upload directly to S3 from browser
261
- const formData = new FormData();
262
- Object.entries(presignedPost.fields).forEach(([key, value]) => {
263
- formData.append(key, value);
264
- });
265
- formData.append('file', file);
266
-
267
- await fetch(presignedPost.url, {
268
- method: 'POST',
269
- body: formData,
270
- });
271
- ```
272
-
273
- ### Image Processing Integration
274
-
275
- ```typescript
276
- // Upload original image
277
- await storage.upload({
278
- path: 'images/original/photo.jpg',
279
- data: imageBuffer,
280
- contentType: 'image/jpeg',
281
- });
282
-
283
- // Generate and upload thumbnails
284
- const thumbnail = await resizeImage(imageBuffer, { width: 200, height: 200 });
285
- await storage.upload({
286
- path: 'images/thumbnails/photo.jpg',
287
- data: thumbnail,
288
- contentType: 'image/jpeg',
289
- });
290
-
291
- const medium = await resizeImage(imageBuffer, { width: 800, height: 800 });
292
- await storage.upload({
293
- path: 'images/medium/photo.jpg',
294
- data: medium,
295
- contentType: 'image/jpeg',
296
- });
297
- ```
298
-
299
- ### File Attachments for Records
300
-
301
- ```typescript
302
- // Attach file to a record
303
- await storage.upload({
304
- path: `attachments/opportunity/${opportunityId}/proposal.pdf`,
305
- data: fileBuffer,
306
- contentType: 'application/pdf',
307
- metadata: {
308
- objectType: 'opportunity',
309
- recordId: opportunityId,
310
- uploadedBy: 'user:123',
68
+ // Optional for S3-compatible services (R2, MinIO, Spaces):
69
+ // endpoint: 'https://r2.cloudflarestorage.com/account-id',
70
+ // forcePathStyle: true,
311
71
  },
312
72
  });
313
-
314
- // List attachments for a record
315
- const attachments = await storage.list(`attachments/opportunity/${opportunityId}`);
316
-
317
- // Delete all attachments when record is deleted
318
- await storage.deleteDirectory(`attachments/opportunity/${opportunityId}`);
319
73
  ```
320
74
 
321
75
  ## REST API Endpoints
322
76
 
323
- ```
324
- POST /api/v1/storage/upload # Upload file
325
- GET /api/v1/storage/download/:path # Download file
326
- DELETE /api/v1/storage/:path # Delete file
327
- GET /api/v1/storage/list # List files
328
- POST /api/v1/storage/signed-url # Generate signed URL
329
- POST /api/v1/storage/upload-url # Generate upload URL
330
- GET /api/v1/storage/metadata/:path # Get file metadata
331
- ```
77
+ All routes are mounted at `/api/v1/storage` (configurable via `basePath`).
332
78
 
333
- ## Client Integration
79
+ | Method | Path | Description |
80
+ |--------|------|-------------|
81
+ | POST | `/upload/presigned` | Get presigned upload URL |
82
+ | POST | `/upload/complete` | Mark upload as committed |
83
+ | POST | `/upload/chunked` | Initiate chunked upload |
84
+ | PUT | `/upload/chunked/:uploadId/chunk/:chunkIndex` | Upload a chunk |
85
+ | POST | `/upload/chunked/:uploadId/complete` | Complete chunked upload |
86
+ | GET | `/upload/chunked/:uploadId/progress` | Get upload progress |
87
+ | GET | `/files/:fileId/url` | Get download URL |
88
+ | PUT | `/_local/raw/:token` | Local raw upload (presigned) |
89
+ | GET | `/_local/raw/:token` | Local raw download (presigned) |
334
90
 
335
- ### React Component Example
91
+ ## Client SDK Usage
336
92
 
337
93
  ```typescript
338
- import { useStorage } from '@objectstack/client-react';
94
+ import { ObjectStackClient } from '@objectstack/client';
339
95
 
340
- function FileUploader() {
341
- const { upload, uploading, progress } = useStorage();
96
+ const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' });
342
97
 
343
- const handleUpload = async (file: File) => {
344
- const path = await upload({
345
- file,
346
- folder: 'documents',
347
- onProgress: (percent) => console.log(`Upload: ${percent}%`),
348
- });
98
+ // Simple upload (presigned URL flow)
99
+ const result = await client.storage.upload(file, 'user');
349
100
 
350
- console.log('Uploaded to:', path);
351
- };
101
+ // Chunked upload for large files
102
+ const session = await client.storage.initChunkedUpload({
103
+ filename: 'large-video.mp4',
104
+ mimeType: 'video/mp4',
105
+ totalSize: file.size,
106
+ });
352
107
 
353
- return (
354
- <div>
355
- <input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
356
- {uploading && <progress value={progress} max="100" />}
357
- </div>
358
- );
359
- }
108
+ // Resume interrupted upload
109
+ const completed = await client.storage.resumeUpload(
110
+ session.data.uploadId,
111
+ file,
112
+ session.data.chunkSize,
113
+ session.data.resumeToken,
114
+ );
360
115
  ```
361
116
 
362
- ## Common Patterns
363
-
364
- ### User Avatar Upload
365
-
366
- ```typescript
367
- async function uploadAvatar(userId: string, imageFile: Buffer) {
368
- // Upload original
369
- const path = `avatars/${userId}/original.jpg`;
370
- await storage.upload({
371
- path,
372
- data: imageFile,
373
- contentType: 'image/jpeg',
374
- });
375
-
376
- // Generate thumbnail
377
- const thumbnail = await resizeImage(imageFile, { width: 128, height: 128 });
378
- await storage.upload({
379
- path: `avatars/${userId}/thumbnail.jpg`,
380
- data: thumbnail,
381
- contentType: 'image/jpeg',
382
- });
117
+ ## Architecture
383
118
 
384
- return {
385
- original: storage.getUrl(path),
386
- thumbnail: storage.getUrl(`avatars/${userId}/thumbnail.jpg`),
387
- };
388
- }
389
119
  ```
390
-
391
- ### Document Management
392
-
393
- ```typescript
394
- async function uploadDocument(doc: {
395
- recordId: string;
396
- file: Buffer;
397
- fileName: string;
398
- uploadedBy: string;
399
- }) {
400
- const path = `documents/${doc.recordId}/${Date.now()}-${doc.fileName}`;
401
-
402
- await storage.upload({
403
- path,
404
- data: doc.file,
405
- contentType: getMimeType(doc.fileName),
406
- metadata: {
407
- recordId: doc.recordId,
408
- uploadedBy: doc.uploadedBy,
409
- fileName: doc.fileName,
410
- },
411
- });
412
-
413
- // Create signed URL for secure download
414
- const downloadUrl = await storage.getSignedUrl(path, { expiresIn: 86400 }); // 24 hours
415
-
416
- return { path, downloadUrl };
417
- }
120
+ ┌──────────────┐ ┌─────────────────────┐ ┌──────────────────┐
121
+ Client SDK │────▶│ REST Routes │────▶│ IStorageService │
122
+ │ (browser) │ │ /api/v1/storage/* │ │ (adapter) │
123
+ └──────────────┘ └─────────────────────┘ └──────────────────┘
124
+ │ │
125
+ ▼ ▼
126
+ ┌─────────────────┐ ┌─────────────────┐
127
+ │ MetadataStore │ │ Filesystem / S3
128
+ (system_file) │ │ (actual bytes) │
129
+ └─────────────────┘ └─────────────────┘
418
130
  ```
419
131
 
420
- ## Best Practices
421
-
422
- 1. **Path Organization**: Use hierarchical paths (e.g., `object/recordId/filename`)
423
- 2. **Content Types**: Always specify correct `contentType`
424
- 3. **Security**: Use signed URLs for private files
425
- 4. **Cleanup**: Delete files when records are deleted
426
- 5. **Validation**: Validate file types and sizes before upload
427
- 6. **Metadata**: Store useful metadata with files
428
- 7. **Backups**: Implement backup strategy for S3 buckets
132
+ ## System Objects
429
133
 
430
- ## Performance Considerations
134
+ The plugin registers two system objects via the manifest service:
431
135
 
432
- - **Streaming**: Use streams for large files to reduce memory usage
433
- - **CDN**: Put CloudFront or similar CDN in front of S3
434
- - **Compression**: Compress files before upload when appropriate
435
- - **Caching**: Cache file URLs and metadata
436
- - **Multipart**: Use multipart upload for files > 5MB
437
-
438
- ## Contract Implementation
439
-
440
- Implements `IStorageService` from `@objectstack/spec/contracts`:
441
-
442
- ```typescript
443
- interface IStorageService {
444
- upload(options: UploadOptions): Promise<void>;
445
- uploadStream(options: UploadStreamOptions): Promise<void>;
446
- download(path: string): Promise<FileData>;
447
- downloadStream(path: string): Promise<ReadableStream>;
448
- delete(path: string): Promise<void>;
449
- exists(path: string): Promise<boolean>;
450
- getMetadata(path: string): Promise<FileMetadata>;
451
- list(path: string, options?: ListOptions): Promise<string[]>;
452
- getUrl(path: string): string;
453
- getSignedUrl(path: string, options?: SignedUrlOptions): Promise<string>;
454
- }
455
- ```
136
+ - **`system_file`** File metadata (fileId, key, name, mimeType, size, scope, status)
137
+ - **`system_upload_session`** Chunked upload state (progress, parts, resumeToken)
456
138
 
457
139
  ## License
458
140
 
459
141
  Apache-2.0
460
-
461
- ## See Also
462
-
463
- - [AWS S3 Documentation](https://docs.aws.amazon.com/s3/)
464
- - [Cloudflare R2 Documentation](https://developers.cloudflare.com/r2/)
465
- - [@objectstack/spec/contracts](../../spec/src/contracts/)
466
- - [File Upload Guide](/content/docs/guides/storage/)