@furystack/s3-blob-store 1.0.0

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 (42) hide show
  1. package/README.md +76 -0
  2. package/esm/define-s3-blob-store.d.ts +36 -0
  3. package/esm/define-s3-blob-store.d.ts.map +1 -0
  4. package/esm/define-s3-blob-store.js +41 -0
  5. package/esm/define-s3-blob-store.js.map +1 -0
  6. package/esm/define-s3-blob-store.spec.d.ts +2 -0
  7. package/esm/define-s3-blob-store.spec.d.ts.map +1 -0
  8. package/esm/define-s3-blob-store.spec.js +86 -0
  9. package/esm/define-s3-blob-store.spec.js.map +1 -0
  10. package/esm/ensure-bucket-lifecycle.d.ts +24 -0
  11. package/esm/ensure-bucket-lifecycle.d.ts.map +1 -0
  12. package/esm/ensure-bucket-lifecycle.js +37 -0
  13. package/esm/ensure-bucket-lifecycle.js.map +1 -0
  14. package/esm/index.d.ts +3 -0
  15. package/esm/index.d.ts.map +1 -0
  16. package/esm/index.js +3 -0
  17. package/esm/index.js.map +1 -0
  18. package/esm/s3-blob-store.d.ts +82 -0
  19. package/esm/s3-blob-store.d.ts.map +1 -0
  20. package/esm/s3-blob-store.integration.spec.d.ts +2 -0
  21. package/esm/s3-blob-store.integration.spec.d.ts.map +1 -0
  22. package/esm/s3-blob-store.integration.spec.js +71 -0
  23. package/esm/s3-blob-store.integration.spec.js.map +1 -0
  24. package/esm/s3-blob-store.js +283 -0
  25. package/esm/s3-blob-store.js.map +1 -0
  26. package/esm/s3-blob-store.unit.spec.d.ts +2 -0
  27. package/esm/s3-blob-store.unit.spec.d.ts.map +1 -0
  28. package/esm/s3-blob-store.unit.spec.js +892 -0
  29. package/esm/s3-blob-store.unit.spec.js.map +1 -0
  30. package/esm/s3-stream-helpers.d.ts +35 -0
  31. package/esm/s3-stream-helpers.d.ts.map +1 -0
  32. package/esm/s3-stream-helpers.js +127 -0
  33. package/esm/s3-stream-helpers.js.map +1 -0
  34. package/package.json +55 -0
  35. package/src/define-s3-blob-store.spec.ts +24 -0
  36. package/src/define-s3-blob-store.ts +43 -0
  37. package/src/ensure-bucket-lifecycle.ts +47 -0
  38. package/src/index.ts +2 -0
  39. package/src/s3-blob-store.integration.spec.ts +78 -0
  40. package/src/s3-blob-store.ts +378 -0
  41. package/src/s3-blob-store.unit.spec.ts +539 -0
  42. package/src/s3-stream-helpers.ts +132 -0
@@ -0,0 +1,892 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { Readable } from 'node:stream';
54
+ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutBucketLifecycleConfigurationCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3';
55
+ import { describe, expect, it, vi } from 'vitest';
56
+ import { BlobStoreError } from '@furystack/blob-store';
57
+ import { S3BlobStore } from './s3-blob-store.js';
58
+ class FakeS3Client {
59
+ handlers;
60
+ sent = [];
61
+ lifecycleAttempts = 0;
62
+ constructor(handlers) {
63
+ this.handlers = handlers;
64
+ }
65
+ send = vi.fn(async (command) => {
66
+ this.sent.push({ name: command.constructor.name, input: command.input });
67
+ if (command.constructor.name === 'PutBucketLifecycleConfigurationCommand') {
68
+ this.lifecycleAttempts += 1;
69
+ }
70
+ const handler = this.handlers[command.constructor.name];
71
+ if (!handler) {
72
+ throw new Error(`No handler for ${command.constructor.name}`);
73
+ }
74
+ return handler(command.input);
75
+ });
76
+ }
77
+ const asClient = (fake) => fake;
78
+ const makeNoSuchKey = () => {
79
+ const err = new Error('NoSuchKey');
80
+ err.name = 'NoSuchKey';
81
+ err.$metadata = { httpStatusCode: 404 };
82
+ return err;
83
+ };
84
+ const decode = (data) => new TextDecoder().decode(data);
85
+ const collect = async (stream) => {
86
+ const reader = stream.getReader();
87
+ const chunks = [];
88
+ for (;;) {
89
+ const { done, value } = await reader.read();
90
+ if (done)
91
+ break;
92
+ chunks.push(value);
93
+ }
94
+ reader.releaseLock();
95
+ const total = chunks.reduce((acc, c) => acc + c.byteLength, 0);
96
+ const merged = new Uint8Array(total);
97
+ let offset = 0;
98
+ for (const chunk of chunks) {
99
+ merged.set(chunk, offset);
100
+ offset += chunk.byteLength;
101
+ }
102
+ return merged;
103
+ };
104
+ describe('S3BlobStore', () => {
105
+ describe('constructor', () => {
106
+ it('throws on missing bucket', () => {
107
+ expect(() => new S3BlobStore({ client: {}, bucket: '' })).toThrow(/non-empty `bucket`/);
108
+ });
109
+ it('defaults storeName to "s3"', () => {
110
+ const env_1 = { stack: [], error: void 0, hasError: false };
111
+ try {
112
+ const store = __addDisposableResource(env_1, new S3BlobStore({ client: {}, bucket: 'b' }), false);
113
+ expect(store.storeName).toBe('s3');
114
+ }
115
+ catch (e_1) {
116
+ env_1.error = e_1;
117
+ env_1.hasError = true;
118
+ }
119
+ finally {
120
+ __disposeResources(env_1);
121
+ }
122
+ });
123
+ it('honours a custom storeName', () => {
124
+ const env_2 = { stack: [], error: void 0, hasError: false };
125
+ try {
126
+ const store = __addDisposableResource(env_2, new S3BlobStore({ client: {}, bucket: 'b', name: 'tenant-a' }), false);
127
+ expect(store.storeName).toBe('tenant-a');
128
+ }
129
+ catch (e_2) {
130
+ env_2.error = e_2;
131
+ env_2.hasError = true;
132
+ }
133
+ finally {
134
+ __disposeResources(env_2);
135
+ }
136
+ });
137
+ it('declares S3-shaped capabilities', () => {
138
+ const env_3 = { stack: [], error: void 0, hasError: false };
139
+ try {
140
+ const store = __addDisposableResource(env_3, new S3BlobStore({ client: {}, bucket: 'b' }), false);
141
+ expect(store.capabilities).toMatchObject({
142
+ presignedUrls: true,
143
+ multipart: false,
144
+ range: true,
145
+ crossNodeAccessible: true,
146
+ });
147
+ }
148
+ catch (e_3) {
149
+ env_3.error = e_3;
150
+ env_3.hasError = true;
151
+ }
152
+ finally {
153
+ __disposeResources(env_3);
154
+ }
155
+ });
156
+ });
157
+ describe('put', () => {
158
+ it('runs an Upload + writes lifecycle on first put', async () => {
159
+ const env_4 = { stack: [], error: void 0, hasError: false };
160
+ try {
161
+ const fake = new FakeS3Client({
162
+ PutBucketLifecycleConfigurationCommand: () => ({}),
163
+ PutObjectCommand: () => ({ ETag: '"abc"' }),
164
+ HeadObjectCommand: () => ({ ContentLength: 5 }),
165
+ });
166
+ const store = __addDisposableResource(env_4, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
167
+ const ref = await store.put('hello', Buffer.from('hello'), { contentType: 'text/plain' });
168
+ expect(ref.etag).toBe('abc');
169
+ expect(ref.contentLength).toBe(5);
170
+ expect(fake.lifecycleAttempts).toBe(1);
171
+ }
172
+ catch (e_4) {
173
+ env_4.error = e_4;
174
+ env_4.hasError = true;
175
+ }
176
+ finally {
177
+ __disposeResources(env_4);
178
+ }
179
+ });
180
+ it('only attempts lifecycle once', async () => {
181
+ const env_5 = { stack: [], error: void 0, hasError: false };
182
+ try {
183
+ const fake = new FakeS3Client({
184
+ PutBucketLifecycleConfigurationCommand: () => ({}),
185
+ PutObjectCommand: () => ({ ETag: '"x"' }),
186
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
187
+ });
188
+ const store = __addDisposableResource(env_5, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
189
+ await store.put('a', Buffer.from('x'));
190
+ await store.put('b', Buffer.from('x'));
191
+ expect(fake.lifecycleAttempts).toBe(1);
192
+ }
193
+ catch (e_5) {
194
+ env_5.error = e_5;
195
+ env_5.hasError = true;
196
+ }
197
+ finally {
198
+ __disposeResources(env_5);
199
+ }
200
+ });
201
+ it('skips lifecycle entirely when manageLifecycle: false', async () => {
202
+ const env_6 = { stack: [], error: void 0, hasError: false };
203
+ try {
204
+ const fake = new FakeS3Client({
205
+ PutObjectCommand: () => ({ ETag: '"x"' }),
206
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
207
+ });
208
+ const store = __addDisposableResource(env_6, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
209
+ await store.put('a', Buffer.from('x'));
210
+ expect(fake.lifecycleAttempts).toBe(0);
211
+ }
212
+ catch (e_6) {
213
+ env_6.error = e_6;
214
+ env_6.hasError = true;
215
+ }
216
+ finally {
217
+ __disposeResources(env_6);
218
+ }
219
+ });
220
+ it('reports lifecycle errors via onLifecycleError', async () => {
221
+ const env_7 = { stack: [], error: void 0, hasError: false };
222
+ try {
223
+ const onLifecycleError = vi.fn();
224
+ const fake = new FakeS3Client({
225
+ PutBucketLifecycleConfigurationCommand: () => {
226
+ throw new Error('AccessDenied');
227
+ },
228
+ PutObjectCommand: () => ({ ETag: '"x"' }),
229
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
230
+ });
231
+ const store = __addDisposableResource(env_7, new S3BlobStore({ client: asClient(fake), bucket: 'b', onLifecycleError }), false);
232
+ await store.put('a', Buffer.from('x'));
233
+ expect(onLifecycleError).toHaveBeenCalledOnce();
234
+ }
235
+ catch (e_7) {
236
+ env_7.error = e_7;
237
+ env_7.hasError = true;
238
+ }
239
+ finally {
240
+ __disposeResources(env_7);
241
+ }
242
+ });
243
+ it('wraps S3 upload failures as io-error', async () => {
244
+ const env_8 = { stack: [], error: void 0, hasError: false };
245
+ try {
246
+ const fake = new FakeS3Client({
247
+ PutBucketLifecycleConfigurationCommand: () => ({}),
248
+ PutObjectCommand: () => {
249
+ throw new Error('s3 down');
250
+ },
251
+ });
252
+ const store = __addDisposableResource(env_8, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
253
+ await expect(store.put('k', Buffer.from('x'))).rejects.toMatchObject({ code: 'io-error' });
254
+ }
255
+ catch (e_8) {
256
+ env_8.error = e_8;
257
+ env_8.hasError = true;
258
+ }
259
+ finally {
260
+ __disposeResources(env_8);
261
+ }
262
+ });
263
+ it('prepends keyPrefix on the wire and strips it on responses', async () => {
264
+ const env_9 = { stack: [], error: void 0, hasError: false };
265
+ try {
266
+ const fake = new FakeS3Client({
267
+ PutBucketLifecycleConfigurationCommand: () => ({}),
268
+ PutObjectCommand: () => ({ ETag: '"x"' }),
269
+ HeadObjectCommand: () => ({ ContentLength: 3 }),
270
+ });
271
+ const store = __addDisposableResource(env_9, new S3BlobStore({ client: asClient(fake), bucket: 'b', keyPrefix: 'tenant-a/' }), false);
272
+ const ref = await store.put('docs/file', Buffer.from('xyz'));
273
+ expect(ref.key).toBe('docs/file');
274
+ const sentPut = fake.sent.find((s) => s.name === 'PutObjectCommand');
275
+ expect((sentPut?.input).Key).toBe('tenant-a/docs/file');
276
+ }
277
+ catch (e_9) {
278
+ env_9.error = e_9;
279
+ env_9.hasError = true;
280
+ }
281
+ finally {
282
+ __disposeResources(env_9);
283
+ }
284
+ });
285
+ it('forwards content type, length and metadata to the Upload', async () => {
286
+ const env_10 = { stack: [], error: void 0, hasError: false };
287
+ try {
288
+ const fake = new FakeS3Client({
289
+ PutBucketLifecycleConfigurationCommand: () => ({}),
290
+ PutObjectCommand: (input) => {
291
+ expect(input).toMatchObject({
292
+ ContentType: 'application/json',
293
+ ContentLength: 4,
294
+ Metadata: { tenant: 't1' },
295
+ });
296
+ return { ETag: '"x"' };
297
+ },
298
+ });
299
+ const store = __addDisposableResource(env_10, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
300
+ const ref = await store.put('k', Buffer.from('abcd'), {
301
+ contentType: 'application/json',
302
+ contentLength: 4,
303
+ metadata: { tenant: 't1' },
304
+ });
305
+ expect(ref.contentLength).toBe(4);
306
+ }
307
+ catch (e_10) {
308
+ env_10.error = e_10;
309
+ env_10.hasError = true;
310
+ }
311
+ finally {
312
+ __disposeResources(env_10);
313
+ }
314
+ });
315
+ it('tolerates HEAD failure when contentLength is unknown', async () => {
316
+ const env_11 = { stack: [], error: void 0, hasError: false };
317
+ try {
318
+ const fake = new FakeS3Client({
319
+ PutBucketLifecycleConfigurationCommand: () => ({}),
320
+ PutObjectCommand: () => ({ ETag: '"x"' }),
321
+ HeadObjectCommand: () => {
322
+ throw new Error('AccessDenied');
323
+ },
324
+ });
325
+ const store = __addDisposableResource(env_11, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
326
+ const ref = await store.put('k', Buffer.from('x'));
327
+ expect(ref.contentLength).toBeUndefined();
328
+ }
329
+ catch (e_11) {
330
+ env_11.error = e_11;
331
+ env_11.hasError = true;
332
+ }
333
+ finally {
334
+ __disposeResources(env_11);
335
+ }
336
+ });
337
+ });
338
+ describe('get', () => {
339
+ it('returns a stream + headers', async () => {
340
+ const env_12 = { stack: [], error: void 0, hasError: false };
341
+ try {
342
+ const fake = new FakeS3Client({
343
+ GetObjectCommand: () => ({
344
+ Body: Readable.from([Buffer.from('payload')]),
345
+ ContentType: 'text/plain',
346
+ ContentLength: 7,
347
+ ETag: '"deadbeef"',
348
+ }),
349
+ });
350
+ const store = __addDisposableResource(env_12, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
351
+ const result = await store.get('k');
352
+ expect(result.contentType).toBe('text/plain');
353
+ expect(result.etag).toBe('deadbeef');
354
+ expect(decode(await collect(result.stream))).toBe('payload');
355
+ }
356
+ catch (e_12) {
357
+ env_12.error = e_12;
358
+ env_12.hasError = true;
359
+ }
360
+ finally {
361
+ __disposeResources(env_12);
362
+ }
363
+ });
364
+ it('handles a Web ReadableStream body', async () => {
365
+ const env_13 = { stack: [], error: void 0, hasError: false };
366
+ try {
367
+ const fake = new FakeS3Client({
368
+ GetObjectCommand: () => ({
369
+ Body: new ReadableStream({
370
+ start(c) {
371
+ c.enqueue(new TextEncoder().encode('web'));
372
+ c.close();
373
+ },
374
+ }),
375
+ ContentLength: 3,
376
+ }),
377
+ });
378
+ const store = __addDisposableResource(env_13, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
379
+ const result = await store.get('k');
380
+ expect(decode(await collect(result.stream))).toBe('web');
381
+ }
382
+ catch (e_13) {
383
+ env_13.error = e_13;
384
+ env_13.hasError = true;
385
+ }
386
+ finally {
387
+ __disposeResources(env_13);
388
+ }
389
+ });
390
+ it('throws not-found when the underlying error is NoSuchKey', async () => {
391
+ const env_14 = { stack: [], error: void 0, hasError: false };
392
+ try {
393
+ const fake = new FakeS3Client({
394
+ GetObjectCommand: () => {
395
+ throw makeNoSuchKey();
396
+ },
397
+ });
398
+ const store = __addDisposableResource(env_14, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
399
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'not-found' });
400
+ }
401
+ catch (e_14) {
402
+ env_14.error = e_14;
403
+ env_14.hasError = true;
404
+ }
405
+ finally {
406
+ __disposeResources(env_14);
407
+ }
408
+ });
409
+ it('wraps generic errors as io-error', async () => {
410
+ const env_15 = { stack: [], error: void 0, hasError: false };
411
+ try {
412
+ const fake = new FakeS3Client({
413
+ GetObjectCommand: () => {
414
+ throw new Error('TLS handshake');
415
+ },
416
+ });
417
+ const store = __addDisposableResource(env_15, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
418
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' });
419
+ }
420
+ catch (e_15) {
421
+ env_15.error = e_15;
422
+ env_15.hasError = true;
423
+ }
424
+ finally {
425
+ __disposeResources(env_15);
426
+ }
427
+ });
428
+ it('throws io-error when S3 returns no body', async () => {
429
+ const env_16 = { stack: [], error: void 0, hasError: false };
430
+ try {
431
+ const fake = new FakeS3Client({
432
+ GetObjectCommand: () => ({ Body: undefined }),
433
+ });
434
+ const store = __addDisposableResource(env_16, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
435
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' });
436
+ }
437
+ catch (e_16) {
438
+ env_16.error = e_16;
439
+ env_16.hasError = true;
440
+ }
441
+ finally {
442
+ __disposeResources(env_16);
443
+ }
444
+ });
445
+ });
446
+ describe('head', () => {
447
+ it('returns metadata', async () => {
448
+ const env_17 = { stack: [], error: void 0, hasError: false };
449
+ try {
450
+ const fake = new FakeS3Client({
451
+ HeadObjectCommand: () => ({
452
+ ContentType: 'text/plain',
453
+ ContentLength: 5,
454
+ ETag: '"v1"',
455
+ LastModified: new Date('2026-01-01T00:00:00Z'),
456
+ Metadata: { tenant: 't1' },
457
+ }),
458
+ });
459
+ const store = __addDisposableResource(env_17, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
460
+ const meta = await store.head('k');
461
+ expect(meta?.contentLength).toBe(5);
462
+ expect(meta?.etag).toBe('v1');
463
+ expect(meta?.customMetadata).toEqual({ tenant: 't1' });
464
+ }
465
+ catch (e_17) {
466
+ env_17.error = e_17;
467
+ env_17.hasError = true;
468
+ }
469
+ finally {
470
+ __disposeResources(env_17);
471
+ }
472
+ });
473
+ it('returns undefined on NoSuchKey', async () => {
474
+ const env_18 = { stack: [], error: void 0, hasError: false };
475
+ try {
476
+ const fake = new FakeS3Client({
477
+ HeadObjectCommand: () => {
478
+ throw makeNoSuchKey();
479
+ },
480
+ });
481
+ const store = __addDisposableResource(env_18, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
482
+ expect(await store.head('k')).toBeUndefined();
483
+ }
484
+ catch (e_18) {
485
+ env_18.error = e_18;
486
+ env_18.hasError = true;
487
+ }
488
+ finally {
489
+ __disposeResources(env_18);
490
+ }
491
+ });
492
+ it('wraps unexpected errors as io-error', async () => {
493
+ const env_19 = { stack: [], error: void 0, hasError: false };
494
+ try {
495
+ const fake = new FakeS3Client({
496
+ HeadObjectCommand: () => {
497
+ throw new Error('throttled');
498
+ },
499
+ });
500
+ const store = __addDisposableResource(env_19, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
501
+ await expect(store.head('k')).rejects.toMatchObject({ code: 'io-error' });
502
+ }
503
+ catch (e_19) {
504
+ env_19.error = e_19;
505
+ env_19.hasError = true;
506
+ }
507
+ finally {
508
+ __disposeResources(env_19);
509
+ }
510
+ });
511
+ });
512
+ describe('delete', () => {
513
+ it('issues DeleteObjectCommand', async () => {
514
+ const env_20 = { stack: [], error: void 0, hasError: false };
515
+ try {
516
+ const fake = new FakeS3Client({
517
+ DeleteObjectCommand: () => ({}),
518
+ });
519
+ const store = __addDisposableResource(env_20, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
520
+ await store.delete('k');
521
+ expect(fake.sent.find((s) => s.name === 'DeleteObjectCommand')).toBeDefined();
522
+ }
523
+ catch (e_20) {
524
+ env_20.error = e_20;
525
+ env_20.hasError = true;
526
+ }
527
+ finally {
528
+ __disposeResources(env_20);
529
+ }
530
+ });
531
+ it('is idempotent on NoSuchKey', async () => {
532
+ const env_21 = { stack: [], error: void 0, hasError: false };
533
+ try {
534
+ const fake = new FakeS3Client({
535
+ DeleteObjectCommand: () => {
536
+ throw makeNoSuchKey();
537
+ },
538
+ });
539
+ const store = __addDisposableResource(env_21, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
540
+ await expect(store.delete('k')).resolves.toBeUndefined();
541
+ }
542
+ catch (e_21) {
543
+ env_21.error = e_21;
544
+ env_21.hasError = true;
545
+ }
546
+ finally {
547
+ __disposeResources(env_21);
548
+ }
549
+ });
550
+ it('wraps unexpected errors as io-error', async () => {
551
+ const env_22 = { stack: [], error: void 0, hasError: false };
552
+ try {
553
+ const fake = new FakeS3Client({
554
+ DeleteObjectCommand: () => {
555
+ throw new Error('AccessDenied');
556
+ },
557
+ });
558
+ const store = __addDisposableResource(env_22, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
559
+ await expect(store.delete('k')).rejects.toMatchObject({ code: 'io-error' });
560
+ }
561
+ catch (e_22) {
562
+ env_22.error = e_22;
563
+ env_22.hasError = true;
564
+ }
565
+ finally {
566
+ __disposeResources(env_22);
567
+ }
568
+ });
569
+ });
570
+ describe('list', () => {
571
+ it('returns items + nextCursor', async () => {
572
+ const env_23 = { stack: [], error: void 0, hasError: false };
573
+ try {
574
+ const fake = new FakeS3Client({
575
+ ListObjectsV2Command: () => ({
576
+ Contents: [
577
+ { Key: 'p/a', Size: 1, ETag: '"e1"', LastModified: new Date(0) },
578
+ { Key: 'p/b', Size: 2 },
579
+ ],
580
+ NextContinuationToken: 'next-page',
581
+ }),
582
+ });
583
+ const store = __addDisposableResource(env_23, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
584
+ const result = await store.list('p/');
585
+ expect(result.items.map((i) => i.key)).toEqual(['p/a', 'p/b']);
586
+ expect(result.nextCursor).toBe('next-page');
587
+ }
588
+ catch (e_23) {
589
+ env_23.error = e_23;
590
+ env_23.hasError = true;
591
+ }
592
+ finally {
593
+ __disposeResources(env_23);
594
+ }
595
+ });
596
+ it('strips keyPrefix on returned keys', async () => {
597
+ const env_24 = { stack: [], error: void 0, hasError: false };
598
+ try {
599
+ const fake = new FakeS3Client({
600
+ ListObjectsV2Command: () => ({
601
+ Contents: [{ Key: 'tenant-a/p/a', Size: 1 }],
602
+ }),
603
+ });
604
+ const store = __addDisposableResource(env_24, new S3BlobStore({
605
+ client: asClient(fake),
606
+ bucket: 'b',
607
+ keyPrefix: 'tenant-a/',
608
+ manageLifecycle: false,
609
+ }), false);
610
+ const result = await store.list('p/');
611
+ expect(result.items[0].key).toBe('p/a');
612
+ }
613
+ catch (e_24) {
614
+ env_24.error = e_24;
615
+ env_24.hasError = true;
616
+ }
617
+ finally {
618
+ __disposeResources(env_24);
619
+ }
620
+ });
621
+ it('rejects non-string prefix and bad limits', async () => {
622
+ const env_25 = { stack: [], error: void 0, hasError: false };
623
+ try {
624
+ const fake = new FakeS3Client({});
625
+ const store = __addDisposableResource(env_25, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
626
+ await expect(store.list(42)).rejects.toMatchObject({ code: 'invalid-config' });
627
+ await expect(store.list('p', { limit: 0 })).rejects.toMatchObject({ code: 'invalid-config' });
628
+ }
629
+ catch (e_25) {
630
+ env_25.error = e_25;
631
+ env_25.hasError = true;
632
+ }
633
+ finally {
634
+ __disposeResources(env_25);
635
+ }
636
+ });
637
+ it('wraps S3 list failures as io-error', async () => {
638
+ const env_26 = { stack: [], error: void 0, hasError: false };
639
+ try {
640
+ const fake = new FakeS3Client({
641
+ ListObjectsV2Command: () => {
642
+ throw new Error('throttled');
643
+ },
644
+ });
645
+ const store = __addDisposableResource(env_26, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
646
+ await expect(store.list('')).rejects.toMatchObject({ code: 'io-error' });
647
+ }
648
+ catch (e_26) {
649
+ env_26.error = e_26;
650
+ env_26.hasError = true;
651
+ }
652
+ finally {
653
+ __disposeResources(env_26);
654
+ }
655
+ });
656
+ it('treats missing Contents as an empty page', async () => {
657
+ const env_27 = { stack: [], error: void 0, hasError: false };
658
+ try {
659
+ const fake = new FakeS3Client({
660
+ ListObjectsV2Command: () => ({}),
661
+ });
662
+ const store = __addDisposableResource(env_27, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
663
+ const result = await store.list('');
664
+ expect(result.items).toEqual([]);
665
+ expect(result.nextCursor).toBeUndefined();
666
+ }
667
+ catch (e_27) {
668
+ env_27.error = e_27;
669
+ env_27.hasError = true;
670
+ }
671
+ finally {
672
+ __disposeResources(env_27);
673
+ }
674
+ });
675
+ it('skips entries without a Key', async () => {
676
+ const env_28 = { stack: [], error: void 0, hasError: false };
677
+ try {
678
+ const fake = new FakeS3Client({
679
+ ListObjectsV2Command: () => ({
680
+ Contents: [{ Size: 1 }, { Key: 'k', Size: 2 }],
681
+ }),
682
+ });
683
+ const store = __addDisposableResource(env_28, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
684
+ const result = await store.list('');
685
+ expect(result.items.map((i) => i.key)).toEqual(['k']);
686
+ }
687
+ catch (e_28) {
688
+ env_28.error = e_28;
689
+ env_28.hasError = true;
690
+ }
691
+ finally {
692
+ __disposeResources(env_28);
693
+ }
694
+ });
695
+ });
696
+ describe('signed URLs', () => {
697
+ const realClient = () => new S3Client({
698
+ region: 'us-east-1',
699
+ endpoint: 'http://127.0.0.1:9000',
700
+ forcePathStyle: true,
701
+ credentials: { accessKeyId: 'AKIA0000', secretAccessKey: 'secret-key' },
702
+ });
703
+ it('mints a download URL pointing at the bucket + key', async () => {
704
+ const env_29 = { stack: [], error: void 0, hasError: false };
705
+ try {
706
+ const store = __addDisposableResource(env_29, new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false }), false);
707
+ const url = await store.getDownloadUrl('a/b', { ttlSec: 60 });
708
+ expect(url.startsWith('http')).toBe(true);
709
+ expect(url).toContain('tests/a/b');
710
+ expect(url).toContain('X-Amz-Signature=');
711
+ }
712
+ catch (e_29) {
713
+ env_29.error = e_29;
714
+ env_29.hasError = true;
715
+ }
716
+ finally {
717
+ __disposeResources(env_29);
718
+ }
719
+ });
720
+ it('mints an upload URL with method PUT', async () => {
721
+ const env_30 = { stack: [], error: void 0, hasError: false };
722
+ try {
723
+ const store = __addDisposableResource(env_30, new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false }), false);
724
+ const result = await store.getUploadUrl('upload/key', { ttlSec: 30, contentType: 'image/png', maxBytes: 1024 });
725
+ expect(result.method).toBe('PUT');
726
+ expect(result.url.startsWith('http')).toBe(true);
727
+ expect(result.url).toContain('tests/upload/key');
728
+ }
729
+ catch (e_30) {
730
+ env_30.error = e_30;
731
+ env_30.hasError = true;
732
+ }
733
+ finally {
734
+ __disposeResources(env_30);
735
+ }
736
+ });
737
+ it('rejects mid-disposal calls', async () => {
738
+ const store = new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false });
739
+ store[Symbol.dispose]();
740
+ await expect(store.getDownloadUrl('a', { ttlSec: 60 })).rejects.toMatchObject({ code: 'io-error' });
741
+ await expect(store.getUploadUrl('a', { ttlSec: 60 })).rejects.toMatchObject({ code: 'io-error' });
742
+ });
743
+ });
744
+ describe('S3BlobStore.bodyToBuffer', () => {
745
+ it('handles Uint8Array', async () => {
746
+ expect(await S3BlobStore.bodyToBuffer(new Uint8Array([1, 2]))).toEqual(new Uint8Array([1, 2]));
747
+ });
748
+ it('handles Buffer', async () => {
749
+ const out = await S3BlobStore.bodyToBuffer(Buffer.from([3, 4]));
750
+ expect(Array.from(out)).toEqual([3, 4]);
751
+ });
752
+ it('handles string', async () => {
753
+ expect(decode(await S3BlobStore.bodyToBuffer('abc'))).toBe('abc');
754
+ });
755
+ it('handles a Web ReadableStream', async () => {
756
+ const body = new ReadableStream({
757
+ start(c) {
758
+ c.enqueue(new TextEncoder().encode('x'));
759
+ c.close();
760
+ },
761
+ });
762
+ expect(decode(await S3BlobStore.bodyToBuffer(body))).toBe('x');
763
+ });
764
+ it('handles a Node Readable', async () => {
765
+ expect(decode(await S3BlobStore.bodyToBuffer(Readable.from([Buffer.from('node')])))).toBe('node');
766
+ });
767
+ it('throws on unsupported body shapes', async () => {
768
+ await expect(S3BlobStore.bodyToBuffer(123)).rejects.toThrow(BlobStoreError);
769
+ });
770
+ });
771
+ describe('verifying command construction', () => {
772
+ it('uses GetObjectCommand on get', async () => {
773
+ const env_31 = { stack: [], error: void 0, hasError: false };
774
+ try {
775
+ const fake = new FakeS3Client({
776
+ GetObjectCommand: () => ({ Body: Readable.from([Buffer.from('x')]), ContentLength: 1 }),
777
+ });
778
+ const store = __addDisposableResource(env_31, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
779
+ await store.get('k');
780
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand);
781
+ }
782
+ catch (e_31) {
783
+ env_31.error = e_31;
784
+ env_31.hasError = true;
785
+ }
786
+ finally {
787
+ __disposeResources(env_31);
788
+ }
789
+ });
790
+ it('uses HeadObjectCommand on head', async () => {
791
+ const env_32 = { stack: [], error: void 0, hasError: false };
792
+ try {
793
+ const fake = new FakeS3Client({ HeadObjectCommand: () => ({ ContentLength: 0 }) });
794
+ const store = __addDisposableResource(env_32, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
795
+ await store.head('k');
796
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(HeadObjectCommand);
797
+ }
798
+ catch (e_32) {
799
+ env_32.error = e_32;
800
+ env_32.hasError = true;
801
+ }
802
+ finally {
803
+ __disposeResources(env_32);
804
+ }
805
+ });
806
+ it('uses DeleteObjectCommand on delete', async () => {
807
+ const env_33 = { stack: [], error: void 0, hasError: false };
808
+ try {
809
+ const fake = new FakeS3Client({ DeleteObjectCommand: () => ({}) });
810
+ const store = __addDisposableResource(env_33, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
811
+ await store.delete('k');
812
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand);
813
+ }
814
+ catch (e_33) {
815
+ env_33.error = e_33;
816
+ env_33.hasError = true;
817
+ }
818
+ finally {
819
+ __disposeResources(env_33);
820
+ }
821
+ });
822
+ it('uses ListObjectsV2Command on list', async () => {
823
+ const env_34 = { stack: [], error: void 0, hasError: false };
824
+ try {
825
+ const fake = new FakeS3Client({ ListObjectsV2Command: () => ({}) });
826
+ const store = __addDisposableResource(env_34, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
827
+ await store.list('');
828
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(ListObjectsV2Command);
829
+ }
830
+ catch (e_34) {
831
+ env_34.error = e_34;
832
+ env_34.hasError = true;
833
+ }
834
+ finally {
835
+ __disposeResources(env_34);
836
+ }
837
+ });
838
+ it('uses PutObjectCommand on put body (verified via Upload single-part path)', async () => {
839
+ const env_35 = { stack: [], error: void 0, hasError: false };
840
+ try {
841
+ const fake = new FakeS3Client({
842
+ PutObjectCommand: () => ({ ETag: '"x"' }),
843
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
844
+ });
845
+ const store = __addDisposableResource(env_35, new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false }), false);
846
+ await store.put('k', Buffer.from('x'));
847
+ const putCall = fake.send.mock.calls.find((c) => c[0] instanceof PutObjectCommand);
848
+ expect(putCall).toBeDefined();
849
+ }
850
+ catch (e_35) {
851
+ env_35.error = e_35;
852
+ env_35.hasError = true;
853
+ }
854
+ finally {
855
+ __disposeResources(env_35);
856
+ }
857
+ });
858
+ it('emits PutBucketLifecycleConfigurationCommand on first put', async () => {
859
+ const env_36 = { stack: [], error: void 0, hasError: false };
860
+ try {
861
+ const fake = new FakeS3Client({
862
+ PutBucketLifecycleConfigurationCommand: () => ({}),
863
+ PutObjectCommand: () => ({ ETag: '"x"' }),
864
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
865
+ });
866
+ const store = __addDisposableResource(env_36, new S3BlobStore({ client: asClient(fake), bucket: 'b' }), false);
867
+ await store.put('k', Buffer.from('x'));
868
+ const lifecycleCall = fake.send.mock.calls.find((c) => c[0] instanceof PutBucketLifecycleConfigurationCommand);
869
+ expect(lifecycleCall).toBeDefined();
870
+ }
871
+ catch (e_36) {
872
+ env_36.error = e_36;
873
+ env_36.hasError = true;
874
+ }
875
+ finally {
876
+ __disposeResources(env_36);
877
+ }
878
+ });
879
+ });
880
+ describe('disposal', () => {
881
+ it('rejects subsequent calls', async () => {
882
+ const store = new S3BlobStore({ client: {}, bucket: 'b' });
883
+ store[Symbol.dispose]();
884
+ await expect(store.put('k', Buffer.from('x'))).rejects.toMatchObject({ code: 'io-error' });
885
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' });
886
+ await expect(store.head('k')).rejects.toMatchObject({ code: 'io-error' });
887
+ await expect(store.delete('k')).rejects.toMatchObject({ code: 'io-error' });
888
+ await expect(store.list('')).rejects.toMatchObject({ code: 'io-error' });
889
+ });
890
+ });
891
+ });
892
+ //# sourceMappingURL=s3-blob-store.unit.spec.js.map