@chidchanun/bcp 0.1.26 → 0.1.28

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,947 @@
1
+ import {
2
+ CopyObjectCommand,
3
+ DeleteObjectsCommand,
4
+ GetObjectCommand,
5
+ HeadObjectCommand,
6
+ ListObjectsV2Command,
7
+ PutObjectCommand,
8
+ } from "@aws-sdk/client-s3";
9
+ import {
10
+ getSignedUrl,
11
+ } from "@aws-sdk/s3-request-presigner";
12
+
13
+ import {
14
+ createS3Storage as createBaseS3Storage,
15
+ type S3StorageAdapter,
16
+ type S3StorageOptions,
17
+ } from "./storage-s3.js";
18
+ import {
19
+ normalizeStorageKey,
20
+ StorageError,
21
+ type StorageObjectMetadata,
22
+ } from "./storage.js";
23
+ import {
24
+ normalizeStorageListLimit,
25
+ normalizeStorageListPrefix,
26
+ normalizeStorageSignedUrlExpiry,
27
+ normalizeStorageUserMetadata,
28
+ type StorageCopyOptions,
29
+ type StorageDeleteManyFailure,
30
+ type StorageDeleteManyResult,
31
+ type StorageEcosystemAdapter,
32
+ type StorageEcosystemPutOptions,
33
+ type StorageEcosystemPutStreamOptions,
34
+ type StorageListOptions,
35
+ type StorageListResult,
36
+ type StorageUserMetadata,
37
+ } from "./storage-ecosystem.js";
38
+
39
+ const S3_DELETE_BATCH_SIZE =
40
+ 1000;
41
+
42
+ export type S3StorageEcosystemAdapter =
43
+ Omit<
44
+ S3StorageAdapter,
45
+ "put" |
46
+ "putStream"
47
+ > &
48
+ StorageEcosystemAdapter;
49
+
50
+ export function createS3Storage(
51
+ options: S3StorageOptions
52
+ ): S3StorageEcosystemAdapter {
53
+ const base =
54
+ createBaseS3Storage(
55
+ options
56
+ );
57
+ const adapter =
58
+ base as unknown as
59
+ S3StorageEcosystemAdapter;
60
+ const bucket =
61
+ assertNonEmptyString(
62
+ options.bucket,
63
+ "bucket"
64
+ );
65
+ const prefix =
66
+ normalizeS3Prefix(
67
+ options.prefix
68
+ );
69
+ const basePut =
70
+ base.put.bind(
71
+ base
72
+ );
73
+ const basePutStream =
74
+ base.putStream?.bind(
75
+ base
76
+ );
77
+ const baseDelete =
78
+ base.delete.bind(
79
+ base
80
+ );
81
+
82
+ adapter.capabilities = {
83
+ ...base.capabilities,
84
+ listing: true,
85
+ signedUrls: true,
86
+ };
87
+
88
+ adapter.put =
89
+ async (
90
+ key,
91
+ value,
92
+ putOptions:
93
+ StorageEcosystemPutOptions = {}
94
+ ) => {
95
+ const stored =
96
+ await basePut(
97
+ key,
98
+ value,
99
+ putOptions
100
+ );
101
+
102
+ if (
103
+ putOptions.metadata !==
104
+ undefined
105
+ ) {
106
+ await setS3Metadata(
107
+ adapter,
108
+ bucket,
109
+ prefix,
110
+ stored.key,
111
+ putOptions.metadata
112
+ );
113
+ }
114
+
115
+ return (
116
+ await adapter.stat(
117
+ stored.key
118
+ )
119
+ ) ?? stored;
120
+ };
121
+
122
+ if (basePutStream) {
123
+ adapter.putStream =
124
+ async (
125
+ key,
126
+ stream,
127
+ putOptions:
128
+ StorageEcosystemPutStreamOptions = {}
129
+ ) => {
130
+ const stored =
131
+ await basePutStream(
132
+ key,
133
+ stream,
134
+ putOptions
135
+ );
136
+
137
+ if (
138
+ putOptions.metadata !==
139
+ undefined
140
+ ) {
141
+ await setS3Metadata(
142
+ adapter,
143
+ bucket,
144
+ prefix,
145
+ stored.key,
146
+ putOptions.metadata
147
+ );
148
+ }
149
+
150
+ return (
151
+ await adapter.stat(
152
+ stored.key
153
+ )
154
+ ) ?? stored;
155
+ };
156
+ }
157
+
158
+ adapter.getMetadata =
159
+ (key) =>
160
+ getS3Metadata(
161
+ adapter,
162
+ bucket,
163
+ prefix,
164
+ key
165
+ );
166
+
167
+ adapter.setMetadata =
168
+ (
169
+ key,
170
+ metadata
171
+ ) =>
172
+ setS3Metadata(
173
+ adapter,
174
+ bucket,
175
+ prefix,
176
+ key,
177
+ metadata
178
+ );
179
+
180
+ adapter.list =
181
+ (listOptions = {}) =>
182
+ listS3Objects(
183
+ adapter,
184
+ bucket,
185
+ prefix,
186
+ listOptions
187
+ );
188
+
189
+ adapter.copy =
190
+ (
191
+ sourceKey,
192
+ destinationKey,
193
+ copyOptions = {}
194
+ ) =>
195
+ copyS3Object(
196
+ adapter,
197
+ bucket,
198
+ prefix,
199
+ sourceKey,
200
+ destinationKey,
201
+ copyOptions
202
+ );
203
+
204
+ adapter.move =
205
+ async (
206
+ sourceKey,
207
+ destinationKey,
208
+ copyOptions = {}
209
+ ) => {
210
+ const source =
211
+ normalizeStorageKey(
212
+ sourceKey
213
+ );
214
+ const destination =
215
+ normalizeStorageKey(
216
+ destinationKey
217
+ );
218
+
219
+ if (source === destination) {
220
+ return requireS3Object(
221
+ adapter,
222
+ source
223
+ );
224
+ }
225
+
226
+ const stored =
227
+ await copyS3Object(
228
+ adapter,
229
+ bucket,
230
+ prefix,
231
+ source,
232
+ destination,
233
+ copyOptions
234
+ );
235
+
236
+ await baseDelete(
237
+ source
238
+ );
239
+
240
+ return stored;
241
+ };
242
+
243
+ adapter.deleteMany =
244
+ (keys) =>
245
+ deleteS3Objects(
246
+ adapter,
247
+ bucket,
248
+ prefix,
249
+ keys
250
+ );
251
+
252
+ adapter.createSignedReadUrl =
253
+ async (
254
+ key,
255
+ signedOptions = {}
256
+ ) => {
257
+ const resolved =
258
+ resolveS3Key(
259
+ prefix,
260
+ key
261
+ );
262
+
263
+ return getSignedUrl(
264
+ adapter.client,
265
+ new GetObjectCommand({
266
+ Bucket:
267
+ bucket,
268
+ Key:
269
+ resolved.providerKey,
270
+ }),
271
+ {
272
+ expiresIn:
273
+ normalizeStorageSignedUrlExpiry(
274
+ signedOptions.expiresIn
275
+ ),
276
+ }
277
+ );
278
+ };
279
+
280
+ adapter.createSignedWriteUrl =
281
+ async (
282
+ key,
283
+ signedOptions = {}
284
+ ) => {
285
+ const resolved =
286
+ resolveS3Key(
287
+ prefix,
288
+ key
289
+ );
290
+ const metadata =
291
+ normalizeStorageUserMetadata(
292
+ signedOptions.metadata
293
+ );
294
+
295
+ return getSignedUrl(
296
+ adapter.client,
297
+ new PutObjectCommand({
298
+ Bucket:
299
+ bucket,
300
+ Key:
301
+ resolved.providerKey,
302
+ ...(signedOptions.contentType
303
+ ? {
304
+ ContentType:
305
+ normalizeContentType(
306
+ signedOptions.contentType
307
+ ),
308
+ }
309
+ : {}),
310
+ ...(Object.keys(
311
+ metadata
312
+ ).length > 0
313
+ ? {
314
+ Metadata:
315
+ metadata,
316
+ }
317
+ : {}),
318
+ }),
319
+ {
320
+ expiresIn:
321
+ normalizeStorageSignedUrlExpiry(
322
+ signedOptions.expiresIn
323
+ ),
324
+ }
325
+ );
326
+ };
327
+
328
+ return adapter;
329
+ }
330
+
331
+ async function listS3Objects(
332
+ adapter: S3StorageEcosystemAdapter,
333
+ bucket: string,
334
+ prefix: string,
335
+ options: StorageListOptions
336
+ ): Promise<StorageListResult> {
337
+ const logicalPrefix =
338
+ normalizeStorageListPrefix(
339
+ options.prefix
340
+ );
341
+ const providerPrefix =
342
+ combineS3Prefix(
343
+ prefix,
344
+ logicalPrefix
345
+ );
346
+ const response =
347
+ await adapter.client.send(
348
+ new ListObjectsV2Command({
349
+ Bucket:
350
+ bucket,
351
+ ...(providerPrefix
352
+ ? {
353
+ Prefix:
354
+ providerPrefix,
355
+ }
356
+ : {}),
357
+ MaxKeys:
358
+ normalizeStorageListLimit(
359
+ options.limit
360
+ ),
361
+ ...(options.cursor
362
+ ? {
363
+ ContinuationToken:
364
+ options.cursor,
365
+ }
366
+ : {}),
367
+ })
368
+ );
369
+ const objects:
370
+ StorageObjectMetadata[] = [];
371
+
372
+ for (
373
+ const object
374
+ of response.Contents ?? []
375
+ ) {
376
+ if (!object.Key) {
377
+ continue;
378
+ }
379
+
380
+ const logicalKey =
381
+ toLogicalS3Key(
382
+ prefix,
383
+ object.Key
384
+ );
385
+
386
+ if (!logicalKey) {
387
+ continue;
388
+ }
389
+
390
+ const size =
391
+ object.Size ?? 0;
392
+ const lastModified =
393
+ object.LastModified ??
394
+ new Date(0);
395
+
396
+ objects.push({
397
+ key:
398
+ logicalKey,
399
+ size,
400
+ contentType:
401
+ "application/octet-stream",
402
+ lastModified:
403
+ new Date(
404
+ lastModified.getTime()
405
+ ),
406
+ etag:
407
+ object.ETag ??
408
+ `W/"${size}-${Math.trunc(
409
+ lastModified.getTime()
410
+ )}"`,
411
+ });
412
+ }
413
+
414
+ return {
415
+ objects,
416
+ ...(response.IsTruncated &&
417
+ response.NextContinuationToken
418
+ ? {
419
+ cursor:
420
+ response.NextContinuationToken,
421
+ }
422
+ : {}),
423
+ };
424
+ }
425
+
426
+ async function copyS3Object(
427
+ adapter: S3StorageEcosystemAdapter,
428
+ bucket: string,
429
+ prefix: string,
430
+ sourceKey: string,
431
+ destinationKey: string,
432
+ options: StorageCopyOptions
433
+ ): Promise<StorageObjectMetadata> {
434
+ const source =
435
+ resolveS3Key(
436
+ prefix,
437
+ sourceKey
438
+ );
439
+ const destination =
440
+ resolveS3Key(
441
+ prefix,
442
+ destinationKey
443
+ );
444
+ const sourceMetadata =
445
+ await requireS3Object(
446
+ adapter,
447
+ source.normalizedKey
448
+ );
449
+
450
+ if (
451
+ source.normalizedKey ===
452
+ destination.normalizedKey
453
+ ) {
454
+ return sourceMetadata;
455
+ }
456
+
457
+ if (
458
+ !options.overwrite &&
459
+ await adapter.exists(
460
+ destination.normalizedKey
461
+ )
462
+ ) {
463
+ throw new StorageError(
464
+ "OBJECT_EXISTS",
465
+ `BCP Framework: storage object "${destination.normalizedKey}" already exists.`,
466
+ 409
467
+ );
468
+ }
469
+
470
+ await adapter.client.send(
471
+ new CopyObjectCommand({
472
+ Bucket:
473
+ bucket,
474
+ Key:
475
+ destination.providerKey,
476
+ CopySource:
477
+ createCopySource(
478
+ bucket,
479
+ source.providerKey
480
+ ),
481
+ MetadataDirective:
482
+ "COPY",
483
+ })
484
+ );
485
+
486
+ return requireS3Object(
487
+ adapter,
488
+ destination.normalizedKey
489
+ );
490
+ }
491
+
492
+ async function getS3Metadata(
493
+ adapter: S3StorageEcosystemAdapter,
494
+ bucket: string,
495
+ prefix: string,
496
+ key: string
497
+ ): Promise<StorageUserMetadata> {
498
+ const resolved =
499
+ resolveS3Key(
500
+ prefix,
501
+ key
502
+ );
503
+
504
+ try {
505
+ const response =
506
+ await adapter.client.send(
507
+ new HeadObjectCommand({
508
+ Bucket:
509
+ bucket,
510
+ Key:
511
+ resolved.providerKey,
512
+ })
513
+ );
514
+ const metadata:
515
+ StorageUserMetadata = {};
516
+
517
+ for (
518
+ const [rawKey, rawValue]
519
+ of Object.entries(
520
+ response.Metadata ?? {}
521
+ )
522
+ ) {
523
+ if (
524
+ rawKey.toLowerCase() ===
525
+ "bcp_sha256" ||
526
+ rawValue === undefined
527
+ ) {
528
+ continue;
529
+ }
530
+
531
+ metadata[
532
+ rawKey.toLowerCase()
533
+ ] = rawValue;
534
+ }
535
+
536
+ return normalizeStorageUserMetadata(
537
+ metadata
538
+ );
539
+ } catch (error) {
540
+ if (
541
+ isS3Status(
542
+ error,
543
+ 404
544
+ )
545
+ ) {
546
+ throw objectNotFound(
547
+ resolved.normalizedKey
548
+ );
549
+ }
550
+
551
+ throw error;
552
+ }
553
+ }
554
+
555
+ async function setS3Metadata(
556
+ adapter: S3StorageEcosystemAdapter,
557
+ bucket: string,
558
+ prefix: string,
559
+ key: string,
560
+ metadata: StorageUserMetadata
561
+ ): Promise<StorageUserMetadata> {
562
+ const resolved =
563
+ resolveS3Key(
564
+ prefix,
565
+ key
566
+ );
567
+ let head;
568
+
569
+ try {
570
+ head =
571
+ await adapter.client.send(
572
+ new HeadObjectCommand({
573
+ Bucket:
574
+ bucket,
575
+ Key:
576
+ resolved.providerKey,
577
+ })
578
+ );
579
+ } catch (error) {
580
+ if (
581
+ isS3Status(
582
+ error,
583
+ 404
584
+ )
585
+ ) {
586
+ throw objectNotFound(
587
+ resolved.normalizedKey
588
+ );
589
+ }
590
+
591
+ throw error;
592
+ }
593
+
594
+ const userMetadata =
595
+ normalizeStorageUserMetadata(
596
+ metadata
597
+ );
598
+ const replacement:
599
+ Record<string, string> = {
600
+ ...userMetadata,
601
+ };
602
+ const checksumSha256 =
603
+ head.Metadata
604
+ ?.bcp_sha256;
605
+
606
+ if (checksumSha256) {
607
+ replacement.bcp_sha256 =
608
+ checksumSha256;
609
+ }
610
+
611
+ await adapter.client.send(
612
+ new CopyObjectCommand({
613
+ Bucket:
614
+ bucket,
615
+ Key:
616
+ resolved.providerKey,
617
+ CopySource:
618
+ createCopySource(
619
+ bucket,
620
+ resolved.providerKey
621
+ ),
622
+ MetadataDirective:
623
+ "REPLACE",
624
+ Metadata:
625
+ replacement,
626
+ ContentType:
627
+ head.ContentType ??
628
+ "application/octet-stream",
629
+ })
630
+ );
631
+
632
+ return {
633
+ ...userMetadata,
634
+ };
635
+ }
636
+
637
+ async function deleteS3Objects(
638
+ adapter: S3StorageEcosystemAdapter,
639
+ bucket: string,
640
+ prefix: string,
641
+ keys: readonly string[]
642
+ ): Promise<StorageDeleteManyResult> {
643
+ const normalizedKeys =
644
+ [
645
+ ...new Set(
646
+ keys.map(
647
+ (key) =>
648
+ normalizeStorageKey(
649
+ key
650
+ )
651
+ )
652
+ ),
653
+ ];
654
+ const deleted:
655
+ string[] = [];
656
+ const failed:
657
+ StorageDeleteManyFailure[] = [];
658
+
659
+ for (
660
+ let offset = 0;
661
+ offset < normalizedKeys.length;
662
+ offset += S3_DELETE_BATCH_SIZE
663
+ ) {
664
+ const batch =
665
+ normalizedKeys.slice(
666
+ offset,
667
+ offset +
668
+ S3_DELETE_BATCH_SIZE
669
+ );
670
+ const byProviderKey =
671
+ new Map<string, string>();
672
+
673
+ for (const key of batch) {
674
+ byProviderKey.set(
675
+ resolveS3Key(
676
+ prefix,
677
+ key
678
+ ).providerKey,
679
+ key
680
+ );
681
+ }
682
+
683
+ const response =
684
+ await adapter.client.send(
685
+ new DeleteObjectsCommand({
686
+ Bucket:
687
+ bucket,
688
+ Delete: {
689
+ Quiet: false,
690
+ Objects:
691
+ [
692
+ ...byProviderKey.keys(),
693
+ ].map(
694
+ (providerKey) => ({
695
+ Key:
696
+ providerKey,
697
+ })
698
+ ),
699
+ },
700
+ })
701
+ );
702
+ const failedKeys =
703
+ new Set<string>();
704
+
705
+ for (
706
+ const error
707
+ of response.Errors ?? []
708
+ ) {
709
+ if (!error.Key) {
710
+ continue;
711
+ }
712
+
713
+ const logicalKey =
714
+ byProviderKey.get(
715
+ error.Key
716
+ );
717
+
718
+ if (!logicalKey) {
719
+ continue;
720
+ }
721
+
722
+ failedKeys.add(
723
+ logicalKey
724
+ );
725
+ failed.push({
726
+ key:
727
+ logicalKey,
728
+ message:
729
+ error.Message ??
730
+ error.Code ??
731
+ "S3 delete failed.",
732
+ });
733
+ }
734
+
735
+ for (const key of batch) {
736
+ if (
737
+ !failedKeys.has(
738
+ key
739
+ )
740
+ ) {
741
+ deleted.push(
742
+ key
743
+ );
744
+ }
745
+ }
746
+ }
747
+
748
+ return {
749
+ deleted,
750
+ missing: [],
751
+ failed,
752
+ };
753
+ }
754
+
755
+ async function requireS3Object(
756
+ adapter: S3StorageAdapter,
757
+ key: string
758
+ ): Promise<StorageObjectMetadata> {
759
+ const metadata =
760
+ await adapter.stat(
761
+ key
762
+ );
763
+
764
+ if (!metadata) {
765
+ throw objectNotFound(
766
+ key
767
+ );
768
+ }
769
+
770
+ return metadata;
771
+ }
772
+
773
+ function resolveS3Key(
774
+ prefix: string,
775
+ key: string
776
+ ): {
777
+ normalizedKey: string;
778
+ providerKey: string;
779
+ } {
780
+ const normalizedKey =
781
+ normalizeStorageKey(
782
+ key
783
+ );
784
+
785
+ return {
786
+ normalizedKey,
787
+ providerKey:
788
+ prefix
789
+ ? `${prefix}/${normalizedKey}`
790
+ : normalizedKey,
791
+ };
792
+ }
793
+
794
+ function normalizeS3Prefix(
795
+ value: string | undefined
796
+ ): string {
797
+ if (value === undefined) {
798
+ return "";
799
+ }
800
+
801
+ const normalized =
802
+ value
803
+ .trim()
804
+ .replace(
805
+ /\\/g,
806
+ "/"
807
+ )
808
+ .replace(
809
+ /^\/+|\/+$/g,
810
+ ""
811
+ );
812
+
813
+ return normalized
814
+ ? normalizeStorageKey(
815
+ normalized
816
+ )
817
+ : "";
818
+ }
819
+
820
+ function combineS3Prefix(
821
+ prefix: string,
822
+ logicalPrefix: string
823
+ ): string {
824
+ if (!prefix) {
825
+ return logicalPrefix;
826
+ }
827
+
828
+ return logicalPrefix
829
+ ? `${prefix}/${logicalPrefix}`
830
+ : `${prefix}/`;
831
+ }
832
+
833
+ function toLogicalS3Key(
834
+ prefix: string,
835
+ providerKey: string
836
+ ): string | null {
837
+ const value =
838
+ prefix
839
+ ? (
840
+ providerKey.startsWith(
841
+ `${prefix}/`
842
+ )
843
+ ? providerKey.slice(
844
+ prefix.length + 1
845
+ )
846
+ : ""
847
+ )
848
+ : providerKey;
849
+
850
+ if (!value) {
851
+ return null;
852
+ }
853
+
854
+ try {
855
+ return normalizeStorageKey(
856
+ value
857
+ );
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+
863
+ function createCopySource(
864
+ bucket: string,
865
+ providerKey: string
866
+ ): string {
867
+ return `/${encodeURIComponent(
868
+ bucket
869
+ )}/${providerKey
870
+ .split("/")
871
+ .map(
872
+ encodeURIComponent
873
+ )
874
+ .join("/")}`;
875
+ }
876
+
877
+ function normalizeContentType(
878
+ value: string
879
+ ): string {
880
+ const normalized =
881
+ value
882
+ .trim()
883
+ .toLowerCase();
884
+
885
+ return (
886
+ !normalized ||
887
+ /[\r\n]/.test(
888
+ normalized
889
+ )
890
+ )
891
+ ? "application/octet-stream"
892
+ : normalized;
893
+ }
894
+
895
+ function objectNotFound(
896
+ key: string
897
+ ): StorageError {
898
+ return new StorageError(
899
+ "OBJECT_NOT_FOUND",
900
+ `BCP Framework: storage object "${key}" was not found.`,
901
+ 404
902
+ );
903
+ }
904
+
905
+ function isS3Status(
906
+ error: unknown,
907
+ status: number
908
+ ): boolean {
909
+ if (
910
+ !error ||
911
+ typeof error !==
912
+ "object"
913
+ ) {
914
+ return false;
915
+ }
916
+
917
+ const metadata =
918
+ "$metadata" in error
919
+ ? (
920
+ error as {
921
+ $metadata?: {
922
+ httpStatusCode?: number;
923
+ };
924
+ }
925
+ ).$metadata
926
+ : undefined;
927
+
928
+ return metadata
929
+ ?.httpStatusCode ===
930
+ status;
931
+ }
932
+
933
+ function assertNonEmptyString(
934
+ value: string,
935
+ label: string
936
+ ): string {
937
+ if (
938
+ typeof value !== "string" ||
939
+ value.trim().length === 0
940
+ ) {
941
+ throw new TypeError(
942
+ `BCP Framework: S3 storage ${label} must be a non-empty string.`
943
+ );
944
+ }
945
+
946
+ return value.trim();
947
+ }