@uploadista/data-store-s3 0.0.3

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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/.turbo/turbo-check.log +5 -0
  3. package/LICENSE +21 -0
  4. package/README.md +588 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/observability.d.ts +45 -0
  9. package/dist/observability.d.ts.map +1 -0
  10. package/dist/observability.js +155 -0
  11. package/dist/s3-store-old.d.ts +51 -0
  12. package/dist/s3-store-old.d.ts.map +1 -0
  13. package/dist/s3-store-old.js +765 -0
  14. package/dist/s3-store.d.ts +9 -0
  15. package/dist/s3-store.d.ts.map +1 -0
  16. package/dist/s3-store.js +666 -0
  17. package/dist/services/__mocks__/s3-client-mock.service.d.ts +44 -0
  18. package/dist/services/__mocks__/s3-client-mock.service.d.ts.map +1 -0
  19. package/dist/services/__mocks__/s3-client-mock.service.js +379 -0
  20. package/dist/services/index.d.ts +2 -0
  21. package/dist/services/index.d.ts.map +1 -0
  22. package/dist/services/index.js +1 -0
  23. package/dist/services/s3-client.service.d.ts +68 -0
  24. package/dist/services/s3-client.service.d.ts.map +1 -0
  25. package/dist/services/s3-client.service.js +209 -0
  26. package/dist/test-observability.d.ts +6 -0
  27. package/dist/test-observability.d.ts.map +1 -0
  28. package/dist/test-observability.js +62 -0
  29. package/dist/types.d.ts +81 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +1 -0
  32. package/dist/utils/calculations.d.ts +7 -0
  33. package/dist/utils/calculations.d.ts.map +1 -0
  34. package/dist/utils/calculations.js +41 -0
  35. package/dist/utils/error-handling.d.ts +7 -0
  36. package/dist/utils/error-handling.d.ts.map +1 -0
  37. package/dist/utils/error-handling.js +29 -0
  38. package/dist/utils/index.d.ts +4 -0
  39. package/dist/utils/index.d.ts.map +1 -0
  40. package/dist/utils/index.js +3 -0
  41. package/dist/utils/stream-adapter.d.ts +14 -0
  42. package/dist/utils/stream-adapter.d.ts.map +1 -0
  43. package/dist/utils/stream-adapter.js +41 -0
  44. package/package.json +36 -0
  45. package/src/__tests__/integration/s3-store.integration.test.ts +548 -0
  46. package/src/__tests__/multipart-logic.test.ts +395 -0
  47. package/src/__tests__/s3-store.edge-cases.test.ts +681 -0
  48. package/src/__tests__/s3-store.performance.test.ts +622 -0
  49. package/src/__tests__/s3-store.test.ts +662 -0
  50. package/src/__tests__/utils/performance-helpers.ts +459 -0
  51. package/src/__tests__/utils/test-data-generator.ts +331 -0
  52. package/src/__tests__/utils/test-setup.ts +256 -0
  53. package/src/index.ts +1 -0
  54. package/src/s3-store.ts +1059 -0
  55. package/src/services/__mocks__/s3-client-mock.service.ts +604 -0
  56. package/src/services/index.ts +1 -0
  57. package/src/services/s3-client.service.ts +359 -0
  58. package/src/types.ts +96 -0
  59. package/src/utils/calculations.ts +61 -0
  60. package/src/utils/error-handling.ts +52 -0
  61. package/src/utils/index.ts +3 -0
  62. package/src/utils/stream-adapter.ts +50 -0
  63. package/tsconfig.json +19 -0
  64. package/tsconfig.tsbuildinfo +1 -0
  65. package/vitest.config.ts +15 -0
@@ -0,0 +1,331 @@
1
+ import type { UploadistaError } from "@uploadista/core/errors";
2
+ import { Option, Stream } from "effect";
3
+
4
+ export interface TestFileSize {
5
+ name: string;
6
+ size: number;
7
+ description: string;
8
+ }
9
+
10
+ export const TEST_FILE_SIZES: Record<string, TestFileSize> = {
11
+ TINY: {
12
+ name: "tiny",
13
+ size: 1024, // 1KB
14
+ description: "Tiny file for edge cases",
15
+ },
16
+ SMALL_BASIC: {
17
+ name: "small-basic",
18
+ size: 1024 * 1024, // 1MB
19
+ description: "Basic small file",
20
+ },
21
+ SMALL_LARGE: {
22
+ name: "small-large",
23
+ size: Math.floor(4.9 * 1024 * 1024), // 4.9MB (just under S3 multipart threshold)
24
+ description: "Large small file (single part)",
25
+ },
26
+ MEDIUM_MIN: {
27
+ name: "medium-min",
28
+ size: 5 * 1024 * 1024, // 5MB (S3 minimum multipart size)
29
+ description: "Minimum medium file (multipart threshold)",
30
+ },
31
+ MEDIUM: {
32
+ name: "medium",
33
+ size: 10 * 1024 * 1024, // 10MB
34
+ description: "Standard medium file",
35
+ },
36
+ MEDIUM_LARGE: {
37
+ name: "medium-large",
38
+ size: 49 * 1024 * 1024, // 49MB
39
+ description: "Large medium file",
40
+ },
41
+ LARGE: {
42
+ name: "large",
43
+ size: 50 * 1024 * 1024, // 50MB
44
+ description: "Standard large file",
45
+ },
46
+ LARGE_XL: {
47
+ name: "large-xl",
48
+ size: 100 * 1024 * 1024, // 100MB
49
+ description: "Extra large file",
50
+ },
51
+ STRESS_TEST: {
52
+ name: "stress-test",
53
+ size: 200 * 1024 * 1024, // 200MB
54
+ description: "Stress test file",
55
+ },
56
+ } as const;
57
+
58
+ export interface TestFilePattern {
59
+ type: "random" | "zeros" | "ones" | "pattern" | "text";
60
+ pattern?: Uint8Array;
61
+ seed?: number;
62
+ }
63
+
64
+ /**
65
+ * Generate test data with different patterns for comprehensive testing
66
+ */
67
+
68
+ /**
69
+ * Generate random data with optional seed for reproducibility
70
+ */
71
+ export function generateRandomData(size: number, seed?: number): Uint8Array {
72
+ const data = new Uint8Array(size);
73
+
74
+ if (seed !== undefined) {
75
+ // Simple LCG for reproducible randomness
76
+ let rng = seed;
77
+ for (let i = 0; i < size; i++) {
78
+ rng = (rng * 1664525 + 1013904223) >>> 0;
79
+ data[i] = (rng >>> 24) & 0xff;
80
+ }
81
+ } else {
82
+ // crypto.getRandomValues has a 65,536 byte limit, so we need to generate in chunks
83
+ const maxChunkSize = 65536;
84
+ let offset = 0;
85
+
86
+ while (offset < size) {
87
+ const chunkSize = Math.min(maxChunkSize, size - offset);
88
+ const chunk = data.subarray(offset, offset + chunkSize);
89
+ crypto.getRandomValues(chunk);
90
+ offset += chunkSize;
91
+ }
92
+ }
93
+
94
+ return data;
95
+ }
96
+
97
+ /**
98
+ * Generate data filled with zeros (good for compression testing)
99
+ */
100
+ export function generateZeroData(size: number): Uint8Array {
101
+ return new Uint8Array(size);
102
+ }
103
+
104
+ /**
105
+ * Generate data filled with ones
106
+ */
107
+ export function generateOnesData(size: number): Uint8Array {
108
+ const data = new Uint8Array(size);
109
+ data.fill(255);
110
+ return data;
111
+ }
112
+
113
+ /**
114
+ * Generate data with repeating pattern
115
+ */
116
+ export function generatePatternData(
117
+ size: number,
118
+ pattern: Uint8Array,
119
+ ): Uint8Array {
120
+ const data = new Uint8Array(size);
121
+ for (let i = 0; i < size; i++) {
122
+ data[i] = pattern[i % pattern.length];
123
+ }
124
+ return data;
125
+ }
126
+
127
+ /**
128
+ * Generate text-like data
129
+ */
130
+ export function generateTextData(size: number): Uint8Array {
131
+ const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
132
+ const encoder = new TextEncoder();
133
+ const baseData = encoder.encode(text);
134
+ const data = new Uint8Array(size);
135
+
136
+ for (let i = 0; i < size; i++) {
137
+ data[i] = baseData[i % baseData.length];
138
+ }
139
+
140
+ return data;
141
+ }
142
+
143
+ /**
144
+ * Generate data based on pattern configuration
145
+ */
146
+ export function generateData(
147
+ size: number,
148
+ pattern: TestFilePattern,
149
+ ): Uint8Array {
150
+ switch (pattern.type) {
151
+ case "random":
152
+ return generateRandomData(size, pattern.seed);
153
+ case "zeros":
154
+ return generateZeroData(size);
155
+ case "ones":
156
+ return generateOnesData(size);
157
+ case "pattern":
158
+ if (!pattern.pattern) {
159
+ throw new Error("Pattern must be provided for pattern type");
160
+ }
161
+ return generatePatternData(size, pattern.pattern);
162
+ case "text":
163
+ return generateTextData(size);
164
+ default:
165
+ throw new Error(`Unknown pattern type: ${pattern.type}`);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create a stream from test data
171
+ */
172
+ export const createTestDataStream = (
173
+ size: number,
174
+ pattern: TestFilePattern = { type: "random" },
175
+ chunkSize: number = 64 * 1024, // 64KB chunks
176
+ ): Stream.Stream<Uint8Array, UploadistaError> => {
177
+ const data = generateData(size, pattern);
178
+
179
+ return Stream.unfold(0, (offset) => {
180
+ if (offset >= data.length) {
181
+ return Option.none();
182
+ }
183
+
184
+ const end = Math.min(offset + chunkSize, data.length);
185
+ const chunk = data.slice(offset, end);
186
+
187
+ return Option.some([chunk, end] as const);
188
+ });
189
+ };
190
+
191
+ /**
192
+ * Create multiple streams for concurrent testing
193
+ */
194
+ export const createMultipleTestStreams = (
195
+ count: number,
196
+ size: number,
197
+ pattern: TestFilePattern = { type: "random" },
198
+ ): Stream.Stream<Uint8Array, UploadistaError>[] => {
199
+ return Array.from({ length: count }, (_, i) =>
200
+ createTestDataStream(size, {
201
+ ...pattern,
202
+ seed: pattern.seed ? pattern.seed + i : i,
203
+ }),
204
+ );
205
+ };
206
+
207
+ /**
208
+ * Utility to compare two Uint8Arrays for testing
209
+ */
210
+ export const compareArrays = (a: Uint8Array, b: Uint8Array): boolean => {
211
+ if (a.length !== b.length) {
212
+ return false;
213
+ }
214
+
215
+ for (let i = 0; i < a.length; i++) {
216
+ if (a[i] !== b[i]) {
217
+ return false;
218
+ }
219
+ }
220
+
221
+ return true;
222
+ };
223
+
224
+ /**
225
+ * Read all data from a stream for comparison
226
+ */
227
+ export const streamToArray = async (
228
+ stream: ReadableStream<Uint8Array>,
229
+ ): Promise<Uint8Array> => {
230
+ const reader = stream.getReader();
231
+ const chunks: Uint8Array[] = [];
232
+ let totalLength = 0;
233
+
234
+ try {
235
+ while (true) {
236
+ const { done, value } = await reader.read();
237
+ if (done) break;
238
+ chunks.push(value);
239
+ totalLength += value.length;
240
+ }
241
+ } finally {
242
+ reader.releaseLock();
243
+ }
244
+
245
+ const result = new Uint8Array(totalLength);
246
+ let offset = 0;
247
+ for (const chunk of chunks) {
248
+ result.set(chunk, offset);
249
+ offset += chunk.length;
250
+ }
251
+
252
+ return result;
253
+ };
254
+
255
+ /**
256
+ * Create test files with metadata for comprehensive testing
257
+ */
258
+ export interface TestFile {
259
+ id: string;
260
+ name: string;
261
+ size: number;
262
+ data: Uint8Array;
263
+ stream: Stream.Stream<Uint8Array, UploadistaError>;
264
+ pattern: TestFilePattern;
265
+ metadata?: {
266
+ contentType?: string;
267
+ cacheControl?: string;
268
+ };
269
+ }
270
+
271
+ export const createTestFile = (
272
+ id: string,
273
+ testSize: TestFileSize,
274
+ pattern: TestFilePattern = { type: "random" },
275
+ metadata?: TestFile["metadata"],
276
+ ): TestFile => {
277
+ const data = generateData(testSize.size, pattern);
278
+
279
+ return {
280
+ id,
281
+ name: testSize.name,
282
+ size: testSize.size,
283
+ data,
284
+ stream: createTestDataStream(testSize.size, pattern),
285
+ pattern,
286
+ metadata,
287
+ };
288
+ };
289
+
290
+ /**
291
+ * Create a set of standard test files for comprehensive testing
292
+ */
293
+ export const createStandardTestFiles = (): TestFile[] => {
294
+ return [
295
+ createTestFile("tiny-random", TEST_FILE_SIZES.TINY, {
296
+ type: "random",
297
+ seed: 1,
298
+ }),
299
+ createTestFile("tiny-zeros", TEST_FILE_SIZES.TINY, { type: "zeros" }),
300
+ createTestFile("small-basic", TEST_FILE_SIZES.SMALL_BASIC, {
301
+ type: "random",
302
+ seed: 2,
303
+ }),
304
+ createTestFile("small-large", TEST_FILE_SIZES.SMALL_LARGE, {
305
+ type: "random",
306
+ seed: 3,
307
+ }),
308
+ createTestFile("medium-min", TEST_FILE_SIZES.MEDIUM_MIN, {
309
+ type: "random",
310
+ seed: 4,
311
+ }),
312
+ createTestFile("medium", TEST_FILE_SIZES.MEDIUM, {
313
+ type: "random",
314
+ seed: 5,
315
+ }),
316
+ createTestFile("large", TEST_FILE_SIZES.LARGE, { type: "random", seed: 6 }),
317
+ createTestFile(
318
+ "text-file",
319
+ TEST_FILE_SIZES.MEDIUM,
320
+ { type: "text" },
321
+ {
322
+ contentType: "text/plain",
323
+ cacheControl: "no-cache",
324
+ },
325
+ ),
326
+ createTestFile("pattern-file", TEST_FILE_SIZES.MEDIUM, {
327
+ type: "pattern",
328
+ pattern: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
329
+ }),
330
+ ];
331
+ };
@@ -0,0 +1,256 @@
1
+ import type { UploadFile } from "@uploadista/core/types";
2
+ import { UploadFileKVStore } from "@uploadista/core/types";
3
+ import { memoryKvStore } from "@uploadista/kv-store-memory";
4
+ import { Effect, Layer } from "effect";
5
+ import {
6
+ MockS3ClientLayer,
7
+ type MockS3Config,
8
+ type MockS3TestMethods,
9
+ } from "../../services/__mocks__/s3-client-mock.service";
10
+ import { S3ClientService } from "../../services/s3-client.service";
11
+
12
+ // Re-export types for tests
13
+ export type { MockS3TestMethods };
14
+
15
+ import type { S3StoreConfig } from "../../types";
16
+
17
+ // Default test configuration
18
+ export const DEFAULT_TEST_CONFIG: MockS3Config = {
19
+ simulateLatency: 0, // No latency by default for faster tests
20
+ errorRate: 0,
21
+ uploadFailureRate: 0,
22
+ enableErrorInjection: true,
23
+ };
24
+
25
+ // Test bucket configuration
26
+ export const TEST_BUCKET = "test-uploadista-bucket";
27
+ export const TEST_DELIVERY_URL = "https://test-cdn.example.com";
28
+
29
+ // Common S3 store configuration for tests
30
+ export const createTestS3StoreConfig = (
31
+ overrides: Partial<S3StoreConfig> = {},
32
+ ): Omit<S3StoreConfig, "kvStore"> => ({
33
+ deliveryUrl: TEST_DELIVERY_URL,
34
+ partSize: 8 * 1024 * 1024, // 8MB default part size
35
+ minPartSize: 5 * 1024 * 1024, // 5MB S3 minimum
36
+ maxMultipartParts: 10_000,
37
+ useTags: true,
38
+ maxConcurrentPartUploads: 10,
39
+ expirationPeriodInMilliseconds: 7 * 24 * 60 * 60 * 1000, // 1 week
40
+ s3ClientConfig: {
41
+ region: "us-east-1",
42
+ bucket: TEST_BUCKET,
43
+ },
44
+ ...overrides,
45
+ });
46
+
47
+ // Layer that provides both KV store and S3 client for testing
48
+ // Creates a fresh isolated KV store for each test
49
+ export const TestLayersWithMockS3 = (
50
+ mockConfig: MockS3Config = DEFAULT_TEST_CONFIG,
51
+ ) =>
52
+ Layer.mergeAll(
53
+ Layer.effect(
54
+ UploadFileKVStore,
55
+ Effect.sync(() => memoryKvStore<UploadFile>()),
56
+ ),
57
+ MockS3ClientLayer(TEST_BUCKET, mockConfig),
58
+ );
59
+
60
+ // Not implemented : Layer with real S3 client (for integration tests with LocalStack/Minio)
61
+ export const TestLayersWithRealS3 = () =>
62
+ Layer.mergeAll(
63
+ Layer.effect(
64
+ UploadFileKVStore,
65
+ Effect.sync(() => memoryKvStore<UploadFile>()),
66
+ ),
67
+ Layer.succeed(S3ClientService, {
68
+ bucket: TEST_BUCKET,
69
+ // Add dummy implementations for the interface
70
+ getObject: () => Effect.die("Not implemented in test setup"),
71
+ headObject: () => Effect.die("Not implemented in test setup"),
72
+ putObject: () => Effect.die("Not implemented in test setup"),
73
+ deleteObject: () => Effect.die("Not implemented in test setup"),
74
+ deleteObjects: () => Effect.die("Not implemented in test setup"),
75
+ createMultipartUpload: () => Effect.die("Not implemented in test setup"),
76
+ uploadPart: () => Effect.die("Not implemented in test setup"),
77
+ completeMultipartUpload: () =>
78
+ Effect.die("Not implemented in test setup"),
79
+ abortMultipartUpload: () => Effect.die("Not implemented in test setup"),
80
+ listParts: () => Effect.die("Not implemented in test setup"),
81
+ listMultipartUploads: () => Effect.die("Not implemented in test setup"),
82
+ getIncompletePart: () => Effect.die("Not implemented in test setup"),
83
+ getIncompletePartSize: () => Effect.die("Not implemented in test setup"),
84
+ putIncompletePart: () => Effect.die("Not implemented in test setup"),
85
+ deleteIncompletePart: () => Effect.die("Not implemented in test setup"),
86
+ }),
87
+ );
88
+
89
+ // Helper to get the mock service with testing methods
90
+ export const getMockS3Service = (): Effect.Effect<
91
+ S3ClientService["Type"] & MockS3TestMethods,
92
+ never,
93
+ S3ClientService
94
+ > =>
95
+ Effect.gen(function* () {
96
+ const service = yield* S3ClientService;
97
+ // Type assertion since we know this is our mock when using TestLayersWithMockS3
98
+ return service as S3ClientService["Type"] & MockS3TestMethods;
99
+ });
100
+
101
+ // Helper to create a test upload file
102
+ export const createTestUploadFile = (
103
+ id: string,
104
+ size: number,
105
+ overrides: Partial<UploadFile> = {},
106
+ ): UploadFile => ({
107
+ id,
108
+ offset: 0,
109
+ size,
110
+ metadata: {
111
+ contentType: "application/octet-stream",
112
+ ...overrides.metadata,
113
+ },
114
+ storage: {
115
+ id: id,
116
+ type: "s3",
117
+ path: id,
118
+ ...overrides.storage,
119
+ },
120
+ url: `${TEST_DELIVERY_URL}/${id}`,
121
+ ...overrides,
122
+ });
123
+
124
+ // Test environment setup that can be used in beforeEach
125
+ export const setupTestEnvironment = (
126
+ mockConfig: MockS3Config = DEFAULT_TEST_CONFIG,
127
+ ) =>
128
+ Effect.gen(function* () {
129
+ const mockService = yield* getMockS3Service();
130
+
131
+ // Clear storage and reset configuration
132
+ yield* mockService.clearStorage();
133
+ yield* mockService.setConfig({ ...DEFAULT_TEST_CONFIG, ...mockConfig });
134
+
135
+ return mockService;
136
+ });
137
+
138
+ // Helper to run tests with proper error handling and logging
139
+ export const runTestWithTimeout = <A, E>(
140
+ effect: Effect.Effect<A, E>,
141
+ timeoutMs: number = 10000,
142
+ ): Promise<A> =>
143
+ Effect.runPromise(
144
+ effect.pipe(
145
+ Effect.timeout(`${timeoutMs} millis`),
146
+ Effect.tapError((error) =>
147
+ Effect.logError("Test failed").pipe(
148
+ Effect.annotateLogs({ error: String(error) }),
149
+ ),
150
+ ),
151
+ ),
152
+ );
153
+
154
+ // Assertion helpers for common test patterns
155
+ export const assertFileUploaded = (
156
+ mockService: S3ClientService["Type"] & MockS3TestMethods,
157
+ fileId: string,
158
+ expectedSize: number,
159
+ ) =>
160
+ Effect.gen(function* () {
161
+ const storage = yield* mockService.getStorage();
162
+ const uploadedFile = storage.objects.get(fileId);
163
+
164
+ if (!uploadedFile) {
165
+ return yield* Effect.fail(new Error(`File ${fileId} was not uploaded`));
166
+ }
167
+
168
+ if (uploadedFile.length !== expectedSize) {
169
+ yield* Effect.fail(
170
+ new Error(
171
+ `File ${fileId} size ${uploadedFile.length} does not match expected ${expectedSize}`,
172
+ ),
173
+ );
174
+ }
175
+
176
+ return uploadedFile;
177
+ });
178
+
179
+ export const assertMultipartUploadExists = (
180
+ mockService: S3ClientService["Type"] & MockS3TestMethods,
181
+ uploadId: string,
182
+ ) =>
183
+ Effect.gen(function* () {
184
+ const storage = yield* mockService.getStorage();
185
+ const upload = storage.multipartUploads.get(uploadId);
186
+
187
+ if (!upload) {
188
+ yield* Effect.fail(new Error(`Multipart upload ${uploadId} not found`));
189
+ }
190
+
191
+ return upload;
192
+ });
193
+
194
+ export const assertMetricsRecorded = (
195
+ mockService: S3ClientService["Type"] & MockS3TestMethods,
196
+ operation: string,
197
+ expectedCount: number = 1,
198
+ ) =>
199
+ Effect.gen(function* () {
200
+ const metrics = yield* mockService.getMetrics();
201
+ const actualCount = metrics.operationCounts.get(operation) || 0;
202
+
203
+ if (actualCount !== expectedCount) {
204
+ yield* Effect.fail(
205
+ new Error(
206
+ `Operation ${operation} was called ${actualCount} times, expected ${expectedCount}`,
207
+ ),
208
+ );
209
+ }
210
+ });
211
+
212
+ // Helper to create test scenarios with different configurations
213
+ export interface TestScenario {
214
+ name: string;
215
+ config: MockS3Config;
216
+ description: string;
217
+ }
218
+
219
+ export const createTestScenarios = (): TestScenario[] => [
220
+ {
221
+ name: "normal",
222
+ config: { ...DEFAULT_TEST_CONFIG },
223
+ description: "Normal operation without errors or latency",
224
+ },
225
+ {
226
+ name: "with-latency",
227
+ config: { ...DEFAULT_TEST_CONFIG, simulateLatency: 10 },
228
+ description: "Operation with 10ms simulated latency",
229
+ },
230
+ {
231
+ name: "with-errors",
232
+ config: { ...DEFAULT_TEST_CONFIG, errorRate: 0.1 },
233
+ description: "Operation with 10% random error rate",
234
+ },
235
+ {
236
+ name: "upload-failures",
237
+ config: { ...DEFAULT_TEST_CONFIG, uploadFailureRate: 0.05 },
238
+ description: "Operation with 5% upload failure rate",
239
+ },
240
+ {
241
+ name: "high-latency",
242
+ config: { ...DEFAULT_TEST_CONFIG, simulateLatency: 100 },
243
+ description: "Operation with 100ms simulated latency",
244
+ },
245
+ ];
246
+
247
+ // Helper for parameterized tests
248
+ export const runParameterizedTest = <T>(
249
+ scenarios: T[],
250
+ testFn: (scenario: T) => Effect.Effect<void, unknown>,
251
+ ) =>
252
+ Effect.gen(function* () {
253
+ for (const scenario of scenarios) {
254
+ yield* testFn(scenario);
255
+ }
256
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./s3-store";