@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,539 @@
1
+ import { Readable } from 'node:stream'
2
+ import {
3
+ DeleteObjectCommand,
4
+ GetObjectCommand,
5
+ HeadObjectCommand,
6
+ ListObjectsV2Command,
7
+ PutBucketLifecycleConfigurationCommand,
8
+ PutObjectCommand,
9
+ S3Client,
10
+ } from '@aws-sdk/client-s3'
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { BlobStoreError } from '@furystack/blob-store'
13
+ import { S3BlobStore } from './s3-blob-store.js'
14
+
15
+ type CommandMatcher = (input: object) => unknown
16
+
17
+ class FakeS3Client {
18
+ public readonly sent: Array<{ name: string; input: object }> = []
19
+ public lifecycleAttempts = 0
20
+
21
+ constructor(private readonly handlers: Record<string, CommandMatcher>) {}
22
+
23
+ public send = vi.fn(async (command: { constructor: { name: string }; input: object }) => {
24
+ this.sent.push({ name: command.constructor.name, input: command.input })
25
+ if (command.constructor.name === 'PutBucketLifecycleConfigurationCommand') {
26
+ this.lifecycleAttempts += 1
27
+ }
28
+ const handler = this.handlers[command.constructor.name]
29
+ if (!handler) {
30
+ throw new Error(`No handler for ${command.constructor.name}`)
31
+ }
32
+ return handler(command.input)
33
+ })
34
+ }
35
+
36
+ const asClient = (fake: FakeS3Client): S3Client => fake as unknown as S3Client
37
+
38
+ const makeNoSuchKey = (): Error => {
39
+ const err = new Error('NoSuchKey') as Error & { name: string; $metadata: { httpStatusCode: number } }
40
+ err.name = 'NoSuchKey'
41
+ err.$metadata = { httpStatusCode: 404 }
42
+ return err
43
+ }
44
+
45
+ const decode = (data: Uint8Array): string => new TextDecoder().decode(data)
46
+
47
+ const collect = async (stream: ReadableStream<Uint8Array>): Promise<Uint8Array> => {
48
+ const reader = stream.getReader()
49
+ const chunks: Uint8Array[] = []
50
+ for (;;) {
51
+ const { done, value } = await reader.read()
52
+ if (done) break
53
+ chunks.push(value)
54
+ }
55
+ reader.releaseLock()
56
+ const total = chunks.reduce((acc, c) => acc + c.byteLength, 0)
57
+ const merged = new Uint8Array(total)
58
+ let offset = 0
59
+ for (const chunk of chunks) {
60
+ merged.set(chunk, offset)
61
+ offset += chunk.byteLength
62
+ }
63
+ return merged
64
+ }
65
+
66
+ describe('S3BlobStore', () => {
67
+ describe('constructor', () => {
68
+ it('throws on missing bucket', () => {
69
+ expect(() => new S3BlobStore({ client: {} as S3Client, bucket: '' })).toThrow(/non-empty `bucket`/)
70
+ })
71
+
72
+ it('defaults storeName to "s3"', () => {
73
+ using store = new S3BlobStore({ client: {} as S3Client, bucket: 'b' })
74
+ expect(store.storeName).toBe('s3')
75
+ })
76
+
77
+ it('honours a custom storeName', () => {
78
+ using store = new S3BlobStore({ client: {} as S3Client, bucket: 'b', name: 'tenant-a' })
79
+ expect(store.storeName).toBe('tenant-a')
80
+ })
81
+
82
+ it('declares S3-shaped capabilities', () => {
83
+ using store = new S3BlobStore({ client: {} as S3Client, bucket: 'b' })
84
+ expect(store.capabilities).toMatchObject({
85
+ presignedUrls: true,
86
+ multipart: false,
87
+ range: true,
88
+ crossNodeAccessible: true,
89
+ })
90
+ })
91
+ })
92
+
93
+ describe('put', () => {
94
+ it('runs an Upload + writes lifecycle on first put', async () => {
95
+ const fake = new FakeS3Client({
96
+ PutBucketLifecycleConfigurationCommand: () => ({}),
97
+ PutObjectCommand: () => ({ ETag: '"abc"' }),
98
+ HeadObjectCommand: () => ({ ContentLength: 5 }),
99
+ })
100
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
101
+ const ref = await store.put('hello', Buffer.from('hello'), { contentType: 'text/plain' })
102
+ expect(ref.etag).toBe('abc')
103
+ expect(ref.contentLength).toBe(5)
104
+ expect(fake.lifecycleAttempts).toBe(1)
105
+ })
106
+
107
+ it('only attempts lifecycle once', async () => {
108
+ const fake = new FakeS3Client({
109
+ PutBucketLifecycleConfigurationCommand: () => ({}),
110
+ PutObjectCommand: () => ({ ETag: '"x"' }),
111
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
112
+ })
113
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
114
+ await store.put('a', Buffer.from('x'))
115
+ await store.put('b', Buffer.from('x'))
116
+ expect(fake.lifecycleAttempts).toBe(1)
117
+ })
118
+
119
+ it('skips lifecycle entirely when manageLifecycle: false', async () => {
120
+ const fake = new FakeS3Client({
121
+ PutObjectCommand: () => ({ ETag: '"x"' }),
122
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
123
+ })
124
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
125
+ await store.put('a', Buffer.from('x'))
126
+ expect(fake.lifecycleAttempts).toBe(0)
127
+ })
128
+
129
+ it('reports lifecycle errors via onLifecycleError', async () => {
130
+ const onLifecycleError = vi.fn()
131
+ const fake = new FakeS3Client({
132
+ PutBucketLifecycleConfigurationCommand: () => {
133
+ throw new Error('AccessDenied')
134
+ },
135
+ PutObjectCommand: () => ({ ETag: '"x"' }),
136
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
137
+ })
138
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', onLifecycleError })
139
+ await store.put('a', Buffer.from('x'))
140
+ expect(onLifecycleError).toHaveBeenCalledOnce()
141
+ })
142
+
143
+ it('wraps S3 upload failures as io-error', async () => {
144
+ const fake = new FakeS3Client({
145
+ PutBucketLifecycleConfigurationCommand: () => ({}),
146
+ PutObjectCommand: () => {
147
+ throw new Error('s3 down')
148
+ },
149
+ })
150
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
151
+ await expect(store.put('k', Buffer.from('x'))).rejects.toMatchObject({ code: 'io-error' })
152
+ })
153
+
154
+ it('prepends keyPrefix on the wire and strips it on responses', async () => {
155
+ const fake = new FakeS3Client({
156
+ PutBucketLifecycleConfigurationCommand: () => ({}),
157
+ PutObjectCommand: () => ({ ETag: '"x"' }),
158
+ HeadObjectCommand: () => ({ ContentLength: 3 }),
159
+ })
160
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', keyPrefix: 'tenant-a/' })
161
+ const ref = await store.put('docs/file', Buffer.from('xyz'))
162
+ expect(ref.key).toBe('docs/file')
163
+ const sentPut = fake.sent.find((s) => s.name === 'PutObjectCommand')
164
+ expect((sentPut?.input as { Key: string }).Key).toBe('tenant-a/docs/file')
165
+ })
166
+
167
+ it('forwards content type, length and metadata to the Upload', async () => {
168
+ const fake = new FakeS3Client({
169
+ PutBucketLifecycleConfigurationCommand: () => ({}),
170
+ PutObjectCommand: (input) => {
171
+ expect(input).toMatchObject({
172
+ ContentType: 'application/json',
173
+ ContentLength: 4,
174
+ Metadata: { tenant: 't1' },
175
+ })
176
+ return { ETag: '"x"' }
177
+ },
178
+ })
179
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
180
+ const ref = await store.put('k', Buffer.from('abcd'), {
181
+ contentType: 'application/json',
182
+ contentLength: 4,
183
+ metadata: { tenant: 't1' },
184
+ })
185
+ expect(ref.contentLength).toBe(4)
186
+ })
187
+
188
+ it('tolerates HEAD failure when contentLength is unknown', async () => {
189
+ const fake = new FakeS3Client({
190
+ PutBucketLifecycleConfigurationCommand: () => ({}),
191
+ PutObjectCommand: () => ({ ETag: '"x"' }),
192
+ HeadObjectCommand: () => {
193
+ throw new Error('AccessDenied')
194
+ },
195
+ })
196
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
197
+ const ref = await store.put('k', Buffer.from('x'))
198
+ expect(ref.contentLength).toBeUndefined()
199
+ })
200
+ })
201
+
202
+ describe('get', () => {
203
+ it('returns a stream + headers', async () => {
204
+ const fake = new FakeS3Client({
205
+ GetObjectCommand: () => ({
206
+ Body: Readable.from([Buffer.from('payload')]),
207
+ ContentType: 'text/plain',
208
+ ContentLength: 7,
209
+ ETag: '"deadbeef"',
210
+ }),
211
+ })
212
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
213
+ const result = await store.get('k')
214
+ expect(result.contentType).toBe('text/plain')
215
+ expect(result.etag).toBe('deadbeef')
216
+ expect(decode(await collect(result.stream))).toBe('payload')
217
+ })
218
+
219
+ it('handles a Web ReadableStream body', async () => {
220
+ const fake = new FakeS3Client({
221
+ GetObjectCommand: () => ({
222
+ Body: new ReadableStream<Uint8Array>({
223
+ start(c) {
224
+ c.enqueue(new TextEncoder().encode('web'))
225
+ c.close()
226
+ },
227
+ }),
228
+ ContentLength: 3,
229
+ }),
230
+ })
231
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
232
+ const result = await store.get('k')
233
+ expect(decode(await collect(result.stream))).toBe('web')
234
+ })
235
+
236
+ it('throws not-found when the underlying error is NoSuchKey', async () => {
237
+ const fake = new FakeS3Client({
238
+ GetObjectCommand: () => {
239
+ throw makeNoSuchKey()
240
+ },
241
+ })
242
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
243
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'not-found' })
244
+ })
245
+
246
+ it('wraps generic errors as io-error', async () => {
247
+ const fake = new FakeS3Client({
248
+ GetObjectCommand: () => {
249
+ throw new Error('TLS handshake')
250
+ },
251
+ })
252
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
253
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' })
254
+ })
255
+
256
+ it('throws io-error when S3 returns no body', async () => {
257
+ const fake = new FakeS3Client({
258
+ GetObjectCommand: () => ({ Body: undefined }),
259
+ })
260
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
261
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' })
262
+ })
263
+ })
264
+
265
+ describe('head', () => {
266
+ it('returns metadata', async () => {
267
+ const fake = new FakeS3Client({
268
+ HeadObjectCommand: () => ({
269
+ ContentType: 'text/plain',
270
+ ContentLength: 5,
271
+ ETag: '"v1"',
272
+ LastModified: new Date('2026-01-01T00:00:00Z'),
273
+ Metadata: { tenant: 't1' },
274
+ }),
275
+ })
276
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
277
+ const meta = await store.head('k')
278
+ expect(meta?.contentLength).toBe(5)
279
+ expect(meta?.etag).toBe('v1')
280
+ expect(meta?.customMetadata).toEqual({ tenant: 't1' })
281
+ })
282
+
283
+ it('returns undefined on NoSuchKey', async () => {
284
+ const fake = new FakeS3Client({
285
+ HeadObjectCommand: () => {
286
+ throw makeNoSuchKey()
287
+ },
288
+ })
289
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
290
+ expect(await store.head('k')).toBeUndefined()
291
+ })
292
+
293
+ it('wraps unexpected errors as io-error', async () => {
294
+ const fake = new FakeS3Client({
295
+ HeadObjectCommand: () => {
296
+ throw new Error('throttled')
297
+ },
298
+ })
299
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
300
+ await expect(store.head('k')).rejects.toMatchObject({ code: 'io-error' })
301
+ })
302
+ })
303
+
304
+ describe('delete', () => {
305
+ it('issues DeleteObjectCommand', async () => {
306
+ const fake = new FakeS3Client({
307
+ DeleteObjectCommand: () => ({}),
308
+ })
309
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
310
+ await store.delete('k')
311
+ expect(fake.sent.find((s) => s.name === 'DeleteObjectCommand')).toBeDefined()
312
+ })
313
+
314
+ it('is idempotent on NoSuchKey', async () => {
315
+ const fake = new FakeS3Client({
316
+ DeleteObjectCommand: () => {
317
+ throw makeNoSuchKey()
318
+ },
319
+ })
320
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
321
+ await expect(store.delete('k')).resolves.toBeUndefined()
322
+ })
323
+
324
+ it('wraps unexpected errors as io-error', async () => {
325
+ const fake = new FakeS3Client({
326
+ DeleteObjectCommand: () => {
327
+ throw new Error('AccessDenied')
328
+ },
329
+ })
330
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
331
+ await expect(store.delete('k')).rejects.toMatchObject({ code: 'io-error' })
332
+ })
333
+ })
334
+
335
+ describe('list', () => {
336
+ it('returns items + nextCursor', async () => {
337
+ const fake = new FakeS3Client({
338
+ ListObjectsV2Command: () => ({
339
+ Contents: [
340
+ { Key: 'p/a', Size: 1, ETag: '"e1"', LastModified: new Date(0) },
341
+ { Key: 'p/b', Size: 2 },
342
+ ],
343
+ NextContinuationToken: 'next-page',
344
+ }),
345
+ })
346
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
347
+ const result = await store.list('p/')
348
+ expect(result.items.map((i) => i.key)).toEqual(['p/a', 'p/b'])
349
+ expect(result.nextCursor).toBe('next-page')
350
+ })
351
+
352
+ it('strips keyPrefix on returned keys', async () => {
353
+ const fake = new FakeS3Client({
354
+ ListObjectsV2Command: () => ({
355
+ Contents: [{ Key: 'tenant-a/p/a', Size: 1 }],
356
+ }),
357
+ })
358
+ using store = new S3BlobStore({
359
+ client: asClient(fake),
360
+ bucket: 'b',
361
+ keyPrefix: 'tenant-a/',
362
+ manageLifecycle: false,
363
+ })
364
+ const result = await store.list('p/')
365
+ expect(result.items[0].key).toBe('p/a')
366
+ })
367
+
368
+ it('rejects non-string prefix and bad limits', async () => {
369
+ const fake = new FakeS3Client({})
370
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
371
+ await expect(store.list(42 as unknown as string)).rejects.toMatchObject({ code: 'invalid-config' })
372
+ await expect(store.list('p', { limit: 0 })).rejects.toMatchObject({ code: 'invalid-config' })
373
+ })
374
+
375
+ it('wraps S3 list failures as io-error', async () => {
376
+ const fake = new FakeS3Client({
377
+ ListObjectsV2Command: () => {
378
+ throw new Error('throttled')
379
+ },
380
+ })
381
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
382
+ await expect(store.list('')).rejects.toMatchObject({ code: 'io-error' })
383
+ })
384
+
385
+ it('treats missing Contents as an empty page', async () => {
386
+ const fake = new FakeS3Client({
387
+ ListObjectsV2Command: () => ({}),
388
+ })
389
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
390
+ const result = await store.list('')
391
+ expect(result.items).toEqual([])
392
+ expect(result.nextCursor).toBeUndefined()
393
+ })
394
+
395
+ it('skips entries without a Key', async () => {
396
+ const fake = new FakeS3Client({
397
+ ListObjectsV2Command: () => ({
398
+ Contents: [{ Size: 1 }, { Key: 'k', Size: 2 }],
399
+ }),
400
+ })
401
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
402
+ const result = await store.list('')
403
+ expect(result.items.map((i) => i.key)).toEqual(['k'])
404
+ })
405
+ })
406
+
407
+ describe('signed URLs', () => {
408
+ const realClient = (): S3Client =>
409
+ new S3Client({
410
+ region: 'us-east-1',
411
+ endpoint: 'http://127.0.0.1:9000',
412
+ forcePathStyle: true,
413
+ credentials: { accessKeyId: 'AKIA0000', secretAccessKey: 'secret-key' },
414
+ })
415
+
416
+ it('mints a download URL pointing at the bucket + key', async () => {
417
+ using store = new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false })
418
+ const url = await store.getDownloadUrl('a/b', { ttlSec: 60 })
419
+ expect(url.startsWith('http')).toBe(true)
420
+ expect(url).toContain('tests/a/b')
421
+ expect(url).toContain('X-Amz-Signature=')
422
+ })
423
+
424
+ it('mints an upload URL with method PUT', async () => {
425
+ using store = new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false })
426
+ const result = await store.getUploadUrl('upload/key', { ttlSec: 30, contentType: 'image/png', maxBytes: 1024 })
427
+ expect(result.method).toBe('PUT')
428
+ expect(result.url.startsWith('http')).toBe(true)
429
+ expect(result.url).toContain('tests/upload/key')
430
+ })
431
+
432
+ it('rejects mid-disposal calls', async () => {
433
+ const store = new S3BlobStore({ client: realClient(), bucket: 'tests', manageLifecycle: false })
434
+ store[Symbol.dispose]()
435
+ await expect(store.getDownloadUrl('a', { ttlSec: 60 })).rejects.toMatchObject({ code: 'io-error' })
436
+ await expect(store.getUploadUrl('a', { ttlSec: 60 })).rejects.toMatchObject({ code: 'io-error' })
437
+ })
438
+ })
439
+
440
+ describe('S3BlobStore.bodyToBuffer', () => {
441
+ it('handles Uint8Array', async () => {
442
+ expect(await S3BlobStore.bodyToBuffer(new Uint8Array([1, 2]))).toEqual(new Uint8Array([1, 2]))
443
+ })
444
+
445
+ it('handles Buffer', async () => {
446
+ const out = await S3BlobStore.bodyToBuffer(Buffer.from([3, 4]))
447
+ expect(Array.from(out)).toEqual([3, 4])
448
+ })
449
+
450
+ it('handles string', async () => {
451
+ expect(decode(await S3BlobStore.bodyToBuffer('abc'))).toBe('abc')
452
+ })
453
+
454
+ it('handles a Web ReadableStream', async () => {
455
+ const body = new ReadableStream<Uint8Array>({
456
+ start(c) {
457
+ c.enqueue(new TextEncoder().encode('x'))
458
+ c.close()
459
+ },
460
+ })
461
+ expect(decode(await S3BlobStore.bodyToBuffer(body))).toBe('x')
462
+ })
463
+
464
+ it('handles a Node Readable', async () => {
465
+ expect(decode(await S3BlobStore.bodyToBuffer(Readable.from([Buffer.from('node')])))).toBe('node')
466
+ })
467
+
468
+ it('throws on unsupported body shapes', async () => {
469
+ await expect(S3BlobStore.bodyToBuffer(123)).rejects.toThrow(BlobStoreError)
470
+ })
471
+ })
472
+
473
+ describe('verifying command construction', () => {
474
+ it('uses GetObjectCommand on get', async () => {
475
+ const fake = new FakeS3Client({
476
+ GetObjectCommand: () => ({ Body: Readable.from([Buffer.from('x')]), ContentLength: 1 }),
477
+ })
478
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
479
+ await store.get('k')
480
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(GetObjectCommand)
481
+ })
482
+
483
+ it('uses HeadObjectCommand on head', async () => {
484
+ const fake = new FakeS3Client({ HeadObjectCommand: () => ({ ContentLength: 0 }) })
485
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
486
+ await store.head('k')
487
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(HeadObjectCommand)
488
+ })
489
+
490
+ it('uses DeleteObjectCommand on delete', async () => {
491
+ const fake = new FakeS3Client({ DeleteObjectCommand: () => ({}) })
492
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
493
+ await store.delete('k')
494
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(DeleteObjectCommand)
495
+ })
496
+
497
+ it('uses ListObjectsV2Command on list', async () => {
498
+ const fake = new FakeS3Client({ ListObjectsV2Command: () => ({}) })
499
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
500
+ await store.list('')
501
+ expect(fake.send.mock.calls[0][0]).toBeInstanceOf(ListObjectsV2Command)
502
+ })
503
+
504
+ it('uses PutObjectCommand on put body (verified via Upload single-part path)', async () => {
505
+ const fake = new FakeS3Client({
506
+ PutObjectCommand: () => ({ ETag: '"x"' }),
507
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
508
+ })
509
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b', manageLifecycle: false })
510
+ await store.put('k', Buffer.from('x'))
511
+ const putCall = fake.send.mock.calls.find((c) => c[0] instanceof PutObjectCommand)
512
+ expect(putCall).toBeDefined()
513
+ })
514
+
515
+ it('emits PutBucketLifecycleConfigurationCommand on first put', async () => {
516
+ const fake = new FakeS3Client({
517
+ PutBucketLifecycleConfigurationCommand: () => ({}),
518
+ PutObjectCommand: () => ({ ETag: '"x"' }),
519
+ HeadObjectCommand: () => ({ ContentLength: 1 }),
520
+ })
521
+ using store = new S3BlobStore({ client: asClient(fake), bucket: 'b' })
522
+ await store.put('k', Buffer.from('x'))
523
+ const lifecycleCall = fake.send.mock.calls.find((c) => c[0] instanceof PutBucketLifecycleConfigurationCommand)
524
+ expect(lifecycleCall).toBeDefined()
525
+ })
526
+ })
527
+
528
+ describe('disposal', () => {
529
+ it('rejects subsequent calls', async () => {
530
+ const store = new S3BlobStore({ client: {} as S3Client, bucket: 'b' })
531
+ store[Symbol.dispose]()
532
+ await expect(store.put('k', Buffer.from('x'))).rejects.toMatchObject({ code: 'io-error' })
533
+ await expect(store.get('k')).rejects.toMatchObject({ code: 'io-error' })
534
+ await expect(store.head('k')).rejects.toMatchObject({ code: 'io-error' })
535
+ await expect(store.delete('k')).rejects.toMatchObject({ code: 'io-error' })
536
+ await expect(store.list('')).rejects.toMatchObject({ code: 'io-error' })
537
+ })
538
+ })
539
+ })
@@ -0,0 +1,132 @@
1
+ import { Readable } from 'node:stream'
2
+ import { BlobStoreError } from '@furystack/blob-store'
3
+ import type { BlobPutInput } from '@furystack/blob-store'
4
+
5
+ /**
6
+ * Drains a Node-style readable stream into a single `Uint8Array`. Memory
7
+ * bound — only safe when the caller knows the payload fits in RAM.
8
+ */
9
+ export const collectNodeStream = async (stream: NodeJS.ReadableStream): Promise<Uint8Array> => {
10
+ const chunks: Buffer[] = []
11
+ for await (const chunk of stream) {
12
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
13
+ }
14
+ return new Uint8Array(Buffer.concat(chunks))
15
+ }
16
+
17
+ /**
18
+ * Adapts a {@link BlobPutInput} to a body shape the AWS SDK
19
+ * `PutObjectCommand` accepts. Web `ReadableStream` payloads are bridged to
20
+ * a Node `Readable` because the v3 SDK does not natively consume web
21
+ * streams.
22
+ */
23
+ export const toUploadBody = (input: BlobPutInput): Readable | Buffer | Uint8Array => {
24
+ if (input instanceof Uint8Array) return input
25
+ if (typeof (input as ReadableStream<Uint8Array>).getReader === 'function') {
26
+ const reader = (input as ReadableStream<Uint8Array>).getReader()
27
+ return new Readable({
28
+ read() {
29
+ reader.read().then(
30
+ ({ done, value }) => {
31
+ if (done) {
32
+ this.push(null)
33
+ return
34
+ }
35
+ this.push(Buffer.from(value))
36
+ },
37
+ (cause: unknown) => {
38
+ this.destroy(cause as Error)
39
+ },
40
+ )
41
+ },
42
+ })
43
+ }
44
+ return Readable.from(input as NodeJS.ReadableStream)
45
+ }
46
+
47
+ const drainNodeStreamToController = async (
48
+ node: NodeJS.ReadableStream,
49
+ controller: ReadableStreamDefaultController<Uint8Array>,
50
+ ): Promise<void> => {
51
+ try {
52
+ for await (const chunk of node) {
53
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
54
+ controller.enqueue(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength))
55
+ }
56
+ controller.close()
57
+ } catch (cause) {
58
+ controller.error(cause)
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Adapts an S3 `GetObjectCommand` body (web stream / Node stream / buffer)
64
+ * to a Web `ReadableStream<Uint8Array>` so callers see a uniform body type
65
+ * regardless of which transport the SDK chose at runtime.
66
+ *
67
+ * Throws {@link BlobStoreError} `code: 'io-error'` when the body shape is
68
+ * unrecognized.
69
+ */
70
+ export const bodyToWebStream = (body: unknown): ReadableStream<Uint8Array> => {
71
+ if (body && typeof body === 'object' && 'getReader' in body) {
72
+ return body as ReadableStream<Uint8Array>
73
+ }
74
+ if (body && typeof body === 'object' && Symbol.asyncIterator in body) {
75
+ const node = body as NodeJS.ReadableStream
76
+ return new ReadableStream<Uint8Array>({
77
+ start(controller) {
78
+ void drainNodeStreamToController(node, controller)
79
+ },
80
+ })
81
+ }
82
+ if (body instanceof Uint8Array) {
83
+ return new ReadableStream<Uint8Array>({
84
+ start(controller) {
85
+ controller.enqueue(body)
86
+ controller.close()
87
+ },
88
+ })
89
+ }
90
+ throw new BlobStoreError('io-error', 'Unsupported S3 response body type')
91
+ }
92
+
93
+ /**
94
+ * Drains an S3 response body into a single `Uint8Array`. Used by
95
+ * {@link S3BlobStore.bodyToBuffer} for callers that want the whole payload
96
+ * in memory.
97
+ */
98
+ export const bodyToBuffer = async (body: unknown): Promise<Uint8Array> => {
99
+ if (body instanceof Uint8Array) return body
100
+ if (Buffer.isBuffer(body)) return new Uint8Array(body)
101
+ if (typeof body === 'string') return new TextEncoder().encode(body)
102
+ if (body && typeof body === 'object' && 'getReader' in body) {
103
+ const reader = (body as ReadableStream<Uint8Array>).getReader()
104
+ const chunks: Uint8Array[] = []
105
+ for (;;) {
106
+ const { done, value } = await reader.read()
107
+ if (done) break
108
+ chunks.push(value)
109
+ }
110
+ const total = chunks.reduce((acc, c) => acc + c.byteLength, 0)
111
+ const merged = new Uint8Array(total)
112
+ let offset = 0
113
+ for (const chunk of chunks) {
114
+ merged.set(chunk, offset)
115
+ offset += chunk.byteLength
116
+ }
117
+ return merged
118
+ }
119
+ if (body && typeof body === 'object' && Symbol.asyncIterator in body) {
120
+ return collectNodeStream(body as NodeJS.ReadableStream)
121
+ }
122
+ throw new BlobStoreError('io-error', 'Unsupported S3 response body type')
123
+ }
124
+
125
+ /**
126
+ * Strips wrapping double-quotes from an S3 ETag header. The SDK preserves
127
+ * the wire format `"<hex>"`; consumers expect a bare hex string.
128
+ */
129
+ export const stripQuotes = (etag: string | undefined): string | undefined => {
130
+ if (etag === undefined) return undefined
131
+ return etag.startsWith('"') && etag.endsWith('"') ? etag.slice(1, -1) : etag
132
+ }