@veloxts/storage 0.6.90 → 0.6.92

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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Storage Provider Compliance Test Suite
3
+ *
4
+ * This module exports a test helper that verifies a StorageStore implementation
5
+ * conforms to the expected interface contract. Use this to test custom providers
6
+ * or verify third-party provider implementations.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { runProviderTests } from '@veloxts/storage/testing';
11
+ * import { createLocalStore } from '@veloxts/storage/drivers/local';
12
+ *
13
+ * runProviderTests('Local', () =>
14
+ * createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
15
+ * );
16
+ * ```
17
+ */
18
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
19
+ import { StorageObjectNotFoundError } from '../errors.js';
20
+ /**
21
+ * Run the provider compliance test suite against a StorageStore implementation.
22
+ *
23
+ * This function runs a comprehensive set of tests to verify that a storage provider
24
+ * correctly implements the StorageStore interface. It covers:
25
+ *
26
+ * - Basic CRUD operations (put, get, delete, deleteMany)
27
+ * - Metadata operations (metadata, head, exists)
28
+ * - Listing files with pagination
29
+ * - Presigned URLs (download and upload)
30
+ * - Copy and move operations
31
+ * - Lifecycle hooks (init, close)
32
+ *
33
+ * @param name - Name of the provider being tested (for test output)
34
+ * @param createProvider - Factory function that creates a new provider instance
35
+ * @param options - Optional test configuration
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // Test the local driver
40
+ * runProviderTests('Local', () =>
41
+ * createLocalStore({ driver: 'local', root: '/tmp/test-storage' })
42
+ * );
43
+ *
44
+ * // Test with options
45
+ * runProviderTests('MinIO', async () => minio({
46
+ * bucket: 'test',
47
+ * endpoint: 'http://localhost:9000',
48
+ * accessKeyId: 'minioadmin',
49
+ * secretAccessKey: 'minioadmin',
50
+ * }), { timeout: 10000 });
51
+ * ```
52
+ */
53
+ export function runProviderTests(name, createProvider, options = {}) {
54
+ const { skipPresignedUrls = false, skipLifecycle = false, timeout = 5000 } = options;
55
+ describe(`StorageProvider compliance: ${name}`, { timeout }, () => {
56
+ let provider;
57
+ const testPrefix = `compliance-test-${Date.now()}`;
58
+ // Helper to generate unique test keys
59
+ const testKey = (suffix) => `${testPrefix}/${suffix}`;
60
+ // Helper to clean up test files
61
+ const cleanup = async () => {
62
+ try {
63
+ const result = await provider.list(testPrefix, { recursive: true });
64
+ if (result.files.length > 0) {
65
+ await provider.deleteMany(result.files.map((f) => f.path));
66
+ }
67
+ }
68
+ catch {
69
+ // Ignore cleanup errors
70
+ }
71
+ };
72
+ beforeAll(async () => {
73
+ provider = await createProvider();
74
+ if (!skipLifecycle && provider.init) {
75
+ await provider.init();
76
+ }
77
+ });
78
+ afterAll(async () => {
79
+ await cleanup();
80
+ await provider.close();
81
+ });
82
+ beforeEach(async () => {
83
+ // Clean state before each test
84
+ });
85
+ afterEach(async () => {
86
+ // Clean up any test files created during the test
87
+ });
88
+ // =========================================================================
89
+ // Basic Operations
90
+ // =========================================================================
91
+ describe('put/get operations', () => {
92
+ it('should put and get a buffer', async () => {
93
+ const key = testKey('buffer-test.txt');
94
+ const content = Buffer.from('Hello, World!');
95
+ const path = await provider.put(key, content, { contentType: 'text/plain' });
96
+ expect(path).toBe(key);
97
+ const result = await provider.get(key);
98
+ expect(result).not.toBeNull();
99
+ expect(result?.toString()).toBe('Hello, World!');
100
+ });
101
+ it('should put and get a string', async () => {
102
+ const key = testKey('string-test.txt');
103
+ const content = 'String content here';
104
+ await provider.put(key, content, { contentType: 'text/plain' });
105
+ const result = await provider.get(key);
106
+ expect(result).not.toBeNull();
107
+ expect(result?.toString()).toBe('String content here');
108
+ });
109
+ it('should return null for non-existent key', async () => {
110
+ const result = await provider.get(testKey('does-not-exist.txt'));
111
+ expect(result).toBeNull();
112
+ });
113
+ it('should overwrite existing file', async () => {
114
+ const key = testKey('overwrite-test.txt');
115
+ await provider.put(key, 'First content');
116
+ await provider.put(key, 'Second content');
117
+ const result = await provider.get(key);
118
+ expect(result?.toString()).toBe('Second content');
119
+ });
120
+ });
121
+ // =========================================================================
122
+ // Metadata Operations
123
+ // =========================================================================
124
+ describe('metadata operations', () => {
125
+ it('should return metadata for existing file', async () => {
126
+ const key = testKey('metadata-test.txt');
127
+ const content = 'Test content for metadata';
128
+ await provider.put(key, content, { contentType: 'text/plain' });
129
+ const meta = await provider.metadata(key);
130
+ expect(meta).not.toBeNull();
131
+ expect(meta?.path).toBe(key);
132
+ expect(meta?.size).toBe(content.length);
133
+ expect(meta?.lastModified).toBeInstanceOf(Date);
134
+ });
135
+ it('should return null for non-existent file', async () => {
136
+ const meta = await provider.metadata(testKey('metadata-not-found.txt'));
137
+ expect(meta).toBeNull();
138
+ });
139
+ });
140
+ describe('head() method', () => {
141
+ it('should return metadata for existing file', async () => {
142
+ const key = testKey('head-test.txt');
143
+ const content = 'Test content for head';
144
+ await provider.put(key, content, { contentType: 'text/plain' });
145
+ const meta = await provider.head(key);
146
+ expect(meta.path).toBe(key);
147
+ expect(meta.size).toBe(content.length);
148
+ expect(meta.lastModified).toBeInstanceOf(Date);
149
+ });
150
+ it('should throw StorageObjectNotFoundError for non-existent file', async () => {
151
+ const key = testKey('head-not-found.txt');
152
+ await expect(provider.head(key)).rejects.toThrow(StorageObjectNotFoundError);
153
+ await expect(provider.head(key)).rejects.toMatchObject({ key });
154
+ });
155
+ });
156
+ describe('exists() method', () => {
157
+ it('should return true for existing file', async () => {
158
+ const key = testKey('exists-test.txt');
159
+ await provider.put(key, 'Test content');
160
+ const exists = await provider.exists(key);
161
+ expect(exists).toBe(true);
162
+ });
163
+ it('should return false for non-existent file', async () => {
164
+ const exists = await provider.exists(testKey('exists-not-found.txt'));
165
+ expect(exists).toBe(false);
166
+ });
167
+ });
168
+ // =========================================================================
169
+ // Delete Operations
170
+ // =========================================================================
171
+ describe('delete operations', () => {
172
+ it('should delete an existing file', async () => {
173
+ const key = testKey('delete-test.txt');
174
+ await provider.put(key, 'Delete me');
175
+ const deleted = await provider.delete(key);
176
+ expect(deleted).toBe(true);
177
+ const exists = await provider.exists(key);
178
+ expect(exists).toBe(false);
179
+ });
180
+ it('should return false when deleting non-existent file', async () => {
181
+ const deleted = await provider.delete(testKey('delete-not-found.txt'));
182
+ expect(deleted).toBe(false);
183
+ });
184
+ it('should batch delete multiple files with deleteMany', async () => {
185
+ const keys = [
186
+ testKey('batch-delete-1.txt'),
187
+ testKey('batch-delete-2.txt'),
188
+ testKey('batch-delete-3.txt'),
189
+ ];
190
+ // Create files
191
+ await Promise.all(keys.map((key) => provider.put(key, `Content for ${key}`)));
192
+ // Delete them
193
+ const count = await provider.deleteMany(keys);
194
+ expect(count).toBe(3);
195
+ // Verify they're gone
196
+ for (const key of keys) {
197
+ const exists = await provider.exists(key);
198
+ expect(exists).toBe(false);
199
+ }
200
+ });
201
+ it('should handle deleteMany with empty array (no-op)', async () => {
202
+ const count = await provider.deleteMany([]);
203
+ expect(count).toBe(0);
204
+ });
205
+ it('should handle deleteMany with mix of existing and non-existing files', async () => {
206
+ const key = testKey('batch-mixed-1.txt');
207
+ await provider.put(key, 'Content');
208
+ const count = await provider.deleteMany([key, testKey('batch-mixed-not-found.txt')]);
209
+ // Should delete at least the existing file
210
+ expect(count).toBeGreaterThanOrEqual(1);
211
+ });
212
+ });
213
+ // =========================================================================
214
+ // List Operations
215
+ // =========================================================================
216
+ describe('list operations', () => {
217
+ const listPrefix = `${testPrefix}/list-test`;
218
+ beforeAll(async () => {
219
+ // Create some test files for listing
220
+ await provider.put(`${listPrefix}/file1.txt`, 'File 1');
221
+ await provider.put(`${listPrefix}/file2.txt`, 'File 2');
222
+ await provider.put(`${listPrefix}/subdir/file3.txt`, 'File 3');
223
+ });
224
+ it('should list files with prefix', async () => {
225
+ const result = await provider.list(listPrefix);
226
+ expect(result.files.length).toBeGreaterThanOrEqual(2);
227
+ expect(result.hasMore).toBeDefined();
228
+ });
229
+ it('should list files recursively', async () => {
230
+ const result = await provider.list(listPrefix, { recursive: true });
231
+ expect(result.files.length).toBeGreaterThanOrEqual(3);
232
+ });
233
+ it('should respect limit option', async () => {
234
+ const result = await provider.list(listPrefix, { recursive: true, limit: 1 });
235
+ expect(result.files.length).toBe(1);
236
+ });
237
+ it('should return file metadata in list results', async () => {
238
+ const result = await provider.list(listPrefix, { recursive: true });
239
+ for (const file of result.files) {
240
+ expect(file.path).toBeDefined();
241
+ expect(file.size).toBeDefined();
242
+ expect(typeof file.size).toBe('number');
243
+ }
244
+ });
245
+ });
246
+ // =========================================================================
247
+ // Copy/Move Operations
248
+ // =========================================================================
249
+ describe('copy/move operations', () => {
250
+ it('should copy a file', async () => {
251
+ const source = testKey('copy-source.txt');
252
+ const dest = testKey('copy-dest.txt');
253
+ await provider.put(source, 'Copy me');
254
+ const resultPath = await provider.copy(source, dest);
255
+ expect(resultPath).toBe(dest);
256
+ // Both should exist
257
+ expect(await provider.exists(source)).toBe(true);
258
+ expect(await provider.exists(dest)).toBe(true);
259
+ // Content should match
260
+ const content = await provider.get(dest);
261
+ expect(content?.toString()).toBe('Copy me');
262
+ });
263
+ it('should move a file', async () => {
264
+ const source = testKey('move-source.txt');
265
+ const dest = testKey('move-dest.txt');
266
+ await provider.put(source, 'Move me');
267
+ const resultPath = await provider.move(source, dest);
268
+ expect(resultPath).toBe(dest);
269
+ // Source should be gone, dest should exist
270
+ expect(await provider.exists(source)).toBe(false);
271
+ expect(await provider.exists(dest)).toBe(true);
272
+ // Content should match
273
+ const content = await provider.get(dest);
274
+ expect(content?.toString()).toBe('Move me');
275
+ });
276
+ });
277
+ // =========================================================================
278
+ // URL Operations
279
+ // =========================================================================
280
+ describe('URL operations', () => {
281
+ it('should generate public URL', async () => {
282
+ const key = testKey('url-test.txt');
283
+ await provider.put(key, 'URL test content');
284
+ const url = await provider.url(key);
285
+ expect(typeof url).toBe('string');
286
+ expect(url.length).toBeGreaterThan(0);
287
+ });
288
+ if (!skipPresignedUrls) {
289
+ it('should generate presigned download URL', async () => {
290
+ const key = testKey('signed-download.txt');
291
+ await provider.put(key, 'Signed download content');
292
+ const signedUrl = await provider.signedUrl(key, { expiresIn: 300 });
293
+ expect(typeof signedUrl).toBe('string');
294
+ expect(signedUrl.length).toBeGreaterThan(0);
295
+ });
296
+ it('should generate presigned upload URL', async () => {
297
+ const key = testKey('signed-upload.txt');
298
+ const uploadUrl = await provider.signedUploadUrl({
299
+ key,
300
+ contentType: 'text/plain',
301
+ expiresIn: 300,
302
+ });
303
+ expect(typeof uploadUrl).toBe('string');
304
+ expect(uploadUrl.length).toBeGreaterThan(0);
305
+ });
306
+ }
307
+ });
308
+ // =========================================================================
309
+ // Stream Operations
310
+ // =========================================================================
311
+ describe('stream operations', () => {
312
+ it('should return readable stream for existing file', async () => {
313
+ const key = testKey('stream-test.txt');
314
+ const content = 'Streamed content';
315
+ await provider.put(key, content);
316
+ const stream = await provider.stream(key);
317
+ expect(stream).not.toBeNull();
318
+ // Read stream to verify content
319
+ if (stream) {
320
+ const chunks = [];
321
+ for await (const chunk of stream) {
322
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
323
+ }
324
+ const result = Buffer.concat(chunks).toString();
325
+ expect(result).toBe(content);
326
+ }
327
+ });
328
+ it('should return null for non-existent file', async () => {
329
+ const stream = await provider.stream(testKey('stream-not-found.txt'));
330
+ expect(stream).toBeNull();
331
+ });
332
+ });
333
+ // =========================================================================
334
+ // Visibility Operations
335
+ // =========================================================================
336
+ describe('visibility operations', () => {
337
+ it('should set and get visibility', async () => {
338
+ const key = testKey('visibility-test.txt');
339
+ await provider.put(key, 'Visibility test');
340
+ await provider.setVisibility(key, 'public');
341
+ // Note: getVisibility may return null for some providers (like S3)
342
+ // that don't easily expose ACL info
343
+ const visibility = await provider.getVisibility(key);
344
+ // Only check if visibility is returned
345
+ if (visibility !== null) {
346
+ expect(visibility).toBe('public');
347
+ }
348
+ });
349
+ });
350
+ // =========================================================================
351
+ // Lifecycle
352
+ // =========================================================================
353
+ if (!skipLifecycle) {
354
+ describe('lifecycle', () => {
355
+ it('should have init method (optional)', () => {
356
+ // init is optional, just verify it exists or doesn't
357
+ expect(typeof provider.init === 'function' || provider.init === undefined).toBe(true);
358
+ });
359
+ it('should have close method', () => {
360
+ expect(typeof provider.close).toBe('function');
361
+ });
362
+ });
363
+ }
364
+ });
365
+ }
package/dist/types.d.ts CHANGED
@@ -75,7 +75,7 @@ export interface ListResult {
75
75
  hasMore: boolean;
76
76
  }
77
77
  /**
78
- * Options for generating signed URLs.
78
+ * Options for generating signed download URLs.
79
79
  */
80
80
  export interface SignedUrlOptions {
81
81
  /** URL expiration time in seconds (default: 3600 = 1 hour) */
@@ -85,6 +85,19 @@ export interface SignedUrlOptions {
85
85
  /** Response content disposition override */
86
86
  responseContentDisposition?: string;
87
87
  }
88
+ /**
89
+ * Options for generating signed upload URLs.
90
+ */
91
+ export interface SignedUploadOptions {
92
+ /** Object key/path to upload to */
93
+ key: string;
94
+ /** URL expiration time in seconds (default: 3600 = 1 hour) */
95
+ expiresIn?: number;
96
+ /** Content type for the upload (required for most providers) */
97
+ contentType?: string;
98
+ /** Maximum content length in bytes (optional enforcement) */
99
+ maxContentLength?: number;
100
+ }
88
101
  /**
89
102
  * Options for copying files.
90
103
  */
@@ -128,6 +141,8 @@ export interface S3StorageConfig {
128
141
  defaultVisibility?: FileVisibility;
129
142
  /** Key prefix for all operations */
130
143
  prefix?: string;
144
+ /** Custom public URL for accessing files (e.g., CDN URL) */
145
+ publicUrl?: string;
131
146
  }
132
147
  /**
133
148
  * Union type for all storage configurations.
@@ -137,6 +152,13 @@ export type StorageConfig = LocalStorageConfig | S3StorageConfig;
137
152
  * Low-level storage store interface implemented by drivers.
138
153
  */
139
154
  export interface StorageStore {
155
+ /**
156
+ * Initialize the storage store.
157
+ * Called once at boot by the plugin.
158
+ * Use for config validation, connection verification, directory creation, etc.
159
+ * Throw to fail fast if the store cannot start.
160
+ */
161
+ init?(): Promise<void>;
140
162
  /**
141
163
  * Upload a file.
142
164
  * @param path - Destination path/key
@@ -199,6 +221,15 @@ export interface StorageStore {
199
221
  * @returns File metadata, or null if not found
200
222
  */
201
223
  metadata(path: string): Promise<FileMetadata | null>;
224
+ /**
225
+ * Get file metadata, throwing if not found.
226
+ * Unlike metadata() which returns null for missing files,
227
+ * head() throws StorageObjectNotFoundError.
228
+ * @param path - File path/key
229
+ * @returns File metadata
230
+ * @throws StorageObjectNotFoundError if file does not exist
231
+ */
232
+ head(path: string): Promise<FileMetadata>;
202
233
  /**
203
234
  * List files in a directory/prefix.
204
235
  * @param prefix - Directory prefix
@@ -213,12 +244,19 @@ export interface StorageStore {
213
244
  */
214
245
  url(path: string): Promise<string>;
215
246
  /**
216
- * Get a signed/temporary URL for private file access.
247
+ * Get a signed/temporary URL for private file access (download).
217
248
  * @param path - File path/key
218
249
  * @param options - Signed URL options
219
250
  * @returns Signed URL string
220
251
  */
221
252
  signedUrl(path: string, options?: SignedUrlOptions): Promise<string>;
253
+ /**
254
+ * Get a signed/temporary URL for uploading a file directly to storage.
255
+ * Enables direct browser-to-storage uploads without proxying through the server.
256
+ * @param options - Signed upload URL options
257
+ * @returns Signed URL string for PUT upload
258
+ */
259
+ signedUploadUrl(options: SignedUploadOptions): Promise<string>;
222
260
  /**
223
261
  * Set file visibility.
224
262
  * @param path - File path/key
@@ -294,3 +332,8 @@ export type StoragePluginOptions = StorageLocalOptions | StorageS3Options | Stor
294
332
  * Storage manager options (alias for plugin options).
295
333
  */
296
334
  export type StorageManagerOptions = StoragePluginOptions;
335
+ /**
336
+ * Backwards compatibility alias for StorageStore.
337
+ * @deprecated Use StorageStore instead. Will be removed in v2.0.
338
+ */
339
+ export type StorageProvider = StorageStore;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veloxts/storage",
3
- "version": "0.6.90",
3
+ "version": "0.6.92",
4
4
  "description": "Multi-driver file storage abstraction for VeloxTS framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,6 +22,30 @@
22
22
  "./drivers/s3": {
23
23
  "types": "./dist/drivers/s3.d.ts",
24
24
  "import": "./dist/drivers/s3.js"
25
+ },
26
+ "./providers": {
27
+ "types": "./dist/providers/index.d.ts",
28
+ "import": "./dist/providers/index.js"
29
+ },
30
+ "./providers/r2": {
31
+ "types": "./dist/providers/r2.d.ts",
32
+ "import": "./dist/providers/r2.js"
33
+ },
34
+ "./providers/minio": {
35
+ "types": "./dist/providers/minio.d.ts",
36
+ "import": "./dist/providers/minio.js"
37
+ },
38
+ "./providers/local": {
39
+ "types": "./dist/drivers/local.d.ts",
40
+ "import": "./dist/drivers/local.js"
41
+ },
42
+ "./providers/s3": {
43
+ "types": "./dist/drivers/s3.d.ts",
44
+ "import": "./dist/drivers/s3.js"
45
+ },
46
+ "./testing": {
47
+ "types": "./dist/testing/index.d.ts",
48
+ "import": "./dist/testing/index.js"
25
49
  }
26
50
  },
27
51
  "files": [
@@ -32,7 +56,8 @@
32
56
  "dependencies": {
33
57
  "fastify-plugin": "5.1.0",
34
58
  "mime-types": "3.0.2",
35
- "@veloxts/core": "0.6.90"
59
+ "zod": "3.24.4",
60
+ "@veloxts/core": "0.6.92"
36
61
  },
37
62
  "peerDependencies": {
38
63
  "@aws-sdk/client-s3": ">=3.0.0",
@@ -61,7 +86,7 @@
61
86
  "fastify": "5.7.2",
62
87
  "typescript": "5.9.3",
63
88
  "vitest": "4.0.18",
64
- "@veloxts/testing": "0.6.90"
89
+ "@veloxts/testing": "0.6.92"
65
90
  },
66
91
  "publishConfig": {
67
92
  "access": "public"