@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,1326 @@
1
+ import {
2
+ createHash,
3
+ } from "node:crypto";
4
+ import {
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import path from "node:path";
12
+
13
+ import {
14
+ createLocalStorage as createBaseLocalStorage,
15
+ getStorageCapabilities,
16
+ normalizeStorageKey,
17
+ putStorageStream,
18
+ readStorageStream,
19
+ StorageError,
20
+ type LocalStorageOptions,
21
+ type StorageAdapter,
22
+ type StorageAdapterCapabilities,
23
+ type StorageObjectMetadata,
24
+ type StoragePutOptions,
25
+ type StoragePutStreamOptions,
26
+ type StorageWriteValue,
27
+ } from "./storage.js";
28
+
29
+ const LOCAL_METADATA_DIRECTORY =
30
+ ".bcp-storage-meta";
31
+ const USER_METADATA_PREFIX =
32
+ "user-";
33
+ const DEFAULT_LIST_LIMIT =
34
+ 100;
35
+ const MAX_LIST_LIMIT =
36
+ 1000;
37
+
38
+ export type StorageUserMetadata =
39
+ Record<string, string>;
40
+
41
+ export interface StorageEcosystemPutOptions
42
+ extends StoragePutOptions {
43
+ metadata?: StorageUserMetadata;
44
+ }
45
+
46
+ export interface StorageEcosystemPutStreamOptions
47
+ extends StoragePutStreamOptions {
48
+ metadata?: StorageUserMetadata;
49
+ }
50
+
51
+ export interface StorageListOptions {
52
+ prefix?: string;
53
+ limit?: number;
54
+ cursor?: string;
55
+ }
56
+
57
+ export interface StorageListResult {
58
+ objects: StorageObjectMetadata[];
59
+ cursor?: string;
60
+ }
61
+
62
+ export interface StorageCopyOptions {
63
+ overwrite?: boolean;
64
+ }
65
+
66
+ export interface StorageSignedUrlOptions {
67
+ expiresIn?: number;
68
+ }
69
+
70
+ export interface StorageSignedWriteUrlOptions
71
+ extends StorageSignedUrlOptions {
72
+ contentType?: string;
73
+ metadata?: StorageUserMetadata;
74
+ }
75
+
76
+ export interface StorageDeleteManyFailure {
77
+ key: string;
78
+ message: string;
79
+ }
80
+
81
+ export interface StorageDeleteManyResult {
82
+ deleted: string[];
83
+ missing: string[];
84
+ failed: StorageDeleteManyFailure[];
85
+ }
86
+
87
+ export interface StorageEcosystemCapabilities
88
+ extends StorageAdapterCapabilities {
89
+ signedReadUrls: boolean;
90
+ signedWriteUrls: boolean;
91
+ copy: boolean;
92
+ move: boolean;
93
+ metadata: boolean;
94
+ bulkDelete: boolean;
95
+ }
96
+
97
+ export interface StorageEcosystemAdapter
98
+ extends StorageAdapter {
99
+ put(
100
+ key: string,
101
+ value: StorageWriteValue,
102
+ options?: StorageEcosystemPutOptions
103
+ ): Promise<StorageObjectMetadata>;
104
+
105
+ putStream?(
106
+ key: string,
107
+ stream: ReadableStream<Uint8Array>,
108
+ options?: StorageEcosystemPutStreamOptions
109
+ ): Promise<StorageObjectMetadata>;
110
+
111
+ list?(
112
+ options?: StorageListOptions
113
+ ): Promise<StorageListResult>;
114
+
115
+ copy?(
116
+ sourceKey: string,
117
+ destinationKey: string,
118
+ options?: StorageCopyOptions
119
+ ): Promise<StorageObjectMetadata>;
120
+
121
+ move?(
122
+ sourceKey: string,
123
+ destinationKey: string,
124
+ options?: StorageCopyOptions
125
+ ): Promise<StorageObjectMetadata>;
126
+
127
+ deleteMany?(
128
+ keys: readonly string[]
129
+ ): Promise<StorageDeleteManyResult>;
130
+
131
+ getMetadata?(
132
+ key: string
133
+ ): Promise<StorageUserMetadata>;
134
+
135
+ setMetadata?(
136
+ key: string,
137
+ metadata: StorageUserMetadata
138
+ ): Promise<StorageUserMetadata>;
139
+
140
+ createSignedReadUrl?(
141
+ key: string,
142
+ options?: StorageSignedUrlOptions
143
+ ): Promise<string>;
144
+
145
+ createSignedWriteUrl?(
146
+ key: string,
147
+ options?: StorageSignedWriteUrlOptions
148
+ ): Promise<string>;
149
+ }
150
+
151
+ export type StorageEcosystemErrorCode =
152
+ "UNSUPPORTED_OPERATION" |
153
+ "INVALID_CURSOR" |
154
+ "INVALID_METADATA";
155
+
156
+ export class StorageEcosystemError
157
+ extends Error {
158
+ readonly code:
159
+ StorageEcosystemErrorCode;
160
+ readonly status:
161
+ number;
162
+
163
+ constructor(
164
+ code: StorageEcosystemErrorCode,
165
+ message: string,
166
+ status = 400
167
+ ) {
168
+ super(message);
169
+ this.name =
170
+ "StorageEcosystemError";
171
+ this.code =
172
+ code;
173
+ this.status =
174
+ status;
175
+ }
176
+ }
177
+
178
+ interface LocalUserMetadataRecord {
179
+ version: 1;
180
+ key: string;
181
+ metadata: StorageUserMetadata;
182
+ }
183
+
184
+ export function createLocalStorage(
185
+ options: LocalStorageOptions
186
+ ): StorageEcosystemAdapter {
187
+ const rootDirectory =
188
+ path.resolve(
189
+ assertNonEmptyString(
190
+ options.directory,
191
+ "directory"
192
+ )
193
+ );
194
+ const metadataDirectory =
195
+ path.join(
196
+ rootDirectory,
197
+ LOCAL_METADATA_DIRECTORY
198
+ );
199
+ const base =
200
+ createBaseLocalStorage(
201
+ options
202
+ );
203
+ const basePut =
204
+ base.put.bind(
205
+ base
206
+ );
207
+ const basePutStream =
208
+ base.putStream?.bind(
209
+ base
210
+ );
211
+ const baseDelete =
212
+ base.delete.bind(
213
+ base
214
+ );
215
+
216
+ const adapter =
217
+ base as StorageEcosystemAdapter;
218
+
219
+ adapter.capabilities = {
220
+ ...base.capabilities,
221
+ listing: true,
222
+ signedUrls: false,
223
+ };
224
+
225
+ adapter.put =
226
+ async (
227
+ key,
228
+ value,
229
+ putOptions = {}
230
+ ) => {
231
+ const normalizedKey =
232
+ normalizeStorageKey(
233
+ key
234
+ );
235
+ const stored =
236
+ await basePut(
237
+ normalizedKey,
238
+ value,
239
+ putOptions
240
+ );
241
+
242
+ await replaceLocalUserMetadata(
243
+ metadataDirectory,
244
+ normalizedKey,
245
+ putOptions.metadata
246
+ );
247
+
248
+ return stored;
249
+ };
250
+
251
+ if (basePutStream) {
252
+ adapter.putStream =
253
+ async (
254
+ key,
255
+ stream,
256
+ putOptions = {}
257
+ ) => {
258
+ const normalizedKey =
259
+ normalizeStorageKey(
260
+ key
261
+ );
262
+ const stored =
263
+ await basePutStream(
264
+ normalizedKey,
265
+ stream,
266
+ putOptions
267
+ );
268
+
269
+ await replaceLocalUserMetadata(
270
+ metadataDirectory,
271
+ normalizedKey,
272
+ putOptions.metadata
273
+ );
274
+
275
+ return stored;
276
+ };
277
+ }
278
+
279
+ adapter.getMetadata =
280
+ async (key) => {
281
+ const normalizedKey =
282
+ normalizeStorageKey(
283
+ key
284
+ );
285
+
286
+ await requireStorageObject(
287
+ adapter,
288
+ normalizedKey
289
+ );
290
+
291
+ return readLocalUserMetadata(
292
+ metadataDirectory,
293
+ normalizedKey
294
+ );
295
+ };
296
+
297
+ adapter.setMetadata =
298
+ async (
299
+ key,
300
+ metadata
301
+ ) => {
302
+ const normalizedKey =
303
+ normalizeStorageKey(
304
+ key
305
+ );
306
+
307
+ await requireStorageObject(
308
+ adapter,
309
+ normalizedKey
310
+ );
311
+
312
+ const normalizedMetadata =
313
+ normalizeStorageUserMetadata(
314
+ metadata
315
+ );
316
+
317
+ await writeLocalUserMetadata(
318
+ metadataDirectory,
319
+ normalizedKey,
320
+ normalizedMetadata
321
+ );
322
+
323
+ return {
324
+ ...normalizedMetadata,
325
+ };
326
+ };
327
+
328
+ adapter.list =
329
+ async (
330
+ listOptions = {}
331
+ ) => {
332
+ const prefix =
333
+ normalizeStorageListPrefix(
334
+ listOptions.prefix
335
+ );
336
+ const limit =
337
+ normalizeStorageListLimit(
338
+ listOptions.limit
339
+ );
340
+ const afterKey =
341
+ decodeLocalCursor(
342
+ listOptions.cursor
343
+ );
344
+ const keys =
345
+ await collectLocalObjectKeys(
346
+ rootDirectory
347
+ );
348
+ const matching =
349
+ keys
350
+ .filter(
351
+ (key) =>
352
+ prefix.length === 0 ||
353
+ key.startsWith(
354
+ prefix
355
+ )
356
+ )
357
+ .filter(
358
+ (key) =>
359
+ afterKey === null ||
360
+ key > afterKey
361
+ )
362
+ .sort();
363
+ const selected =
364
+ matching.slice(
365
+ 0,
366
+ limit
367
+ );
368
+ const objects =
369
+ (
370
+ await Promise.all(
371
+ selected.map(
372
+ (key) =>
373
+ adapter.stat(
374
+ key
375
+ )
376
+ )
377
+ )
378
+ ).filter(
379
+ (
380
+ value
381
+ ): value is StorageObjectMetadata =>
382
+ value !== null
383
+ );
384
+
385
+ return {
386
+ objects,
387
+ ...(matching.length > selected.length &&
388
+ selected.length > 0
389
+ ? {
390
+ cursor:
391
+ encodeLocalCursor(
392
+ selected[
393
+ selected.length - 1
394
+ ]!
395
+ ),
396
+ }
397
+ : {}),
398
+ };
399
+ };
400
+
401
+ adapter.copy =
402
+ async (
403
+ sourceKey,
404
+ destinationKey,
405
+ copyOptions = {}
406
+ ) =>
407
+ copyStorageObjectFallback(
408
+ adapter,
409
+ sourceKey,
410
+ destinationKey,
411
+ copyOptions
412
+ );
413
+
414
+ adapter.move =
415
+ async (
416
+ sourceKey,
417
+ destinationKey,
418
+ copyOptions = {}
419
+ ) => {
420
+ const source =
421
+ normalizeStorageKey(
422
+ sourceKey
423
+ );
424
+ const destination =
425
+ normalizeStorageKey(
426
+ destinationKey
427
+ );
428
+
429
+ if (source === destination) {
430
+ return requireStorageObject(
431
+ adapter,
432
+ source
433
+ );
434
+ }
435
+
436
+ const stored =
437
+ await copyStorageObjectFallback(
438
+ adapter,
439
+ source,
440
+ destination,
441
+ copyOptions
442
+ );
443
+
444
+ await adapter.delete(
445
+ source
446
+ );
447
+
448
+ return stored;
449
+ };
450
+
451
+ adapter.delete =
452
+ async (key) => {
453
+ const normalizedKey =
454
+ normalizeStorageKey(
455
+ key
456
+ );
457
+ const deleted =
458
+ await baseDelete(
459
+ normalizedKey
460
+ );
461
+
462
+ if (deleted) {
463
+ await rm(
464
+ localUserMetadataFile(
465
+ metadataDirectory,
466
+ normalizedKey
467
+ ),
468
+ {
469
+ force: true,
470
+ }
471
+ );
472
+ }
473
+
474
+ return deleted;
475
+ };
476
+
477
+ adapter.deleteMany =
478
+ async (keys) =>
479
+ deleteStorageObjectsFallback(
480
+ adapter,
481
+ keys
482
+ );
483
+
484
+ return adapter;
485
+ }
486
+
487
+ export function getStorageEcosystemCapabilities(
488
+ storage: StorageAdapter
489
+ ): StorageEcosystemCapabilities {
490
+ const base =
491
+ getStorageCapabilities(
492
+ storage
493
+ );
494
+ const ecosystem =
495
+ storage as StorageEcosystemAdapter;
496
+
497
+ return {
498
+ ...base,
499
+ signedUrls:
500
+ base.signedUrls ||
501
+ typeof ecosystem.createSignedReadUrl ===
502
+ "function" ||
503
+ typeof ecosystem.createSignedWriteUrl ===
504
+ "function",
505
+ listing:
506
+ base.listing ||
507
+ typeof ecosystem.list ===
508
+ "function",
509
+ signedReadUrls:
510
+ typeof ecosystem.createSignedReadUrl ===
511
+ "function",
512
+ signedWriteUrls:
513
+ typeof ecosystem.createSignedWriteUrl ===
514
+ "function",
515
+ copy:
516
+ typeof ecosystem.copy ===
517
+ "function",
518
+ move:
519
+ typeof ecosystem.move ===
520
+ "function",
521
+ metadata:
522
+ typeof ecosystem.getMetadata ===
523
+ "function" &&
524
+ typeof ecosystem.setMetadata ===
525
+ "function",
526
+ bulkDelete:
527
+ typeof ecosystem.deleteMany ===
528
+ "function",
529
+ };
530
+ }
531
+
532
+ export async function listStorageObjects(
533
+ storage: StorageAdapter,
534
+ options: StorageListOptions = {}
535
+ ): Promise<StorageListResult> {
536
+ const ecosystem =
537
+ storage as StorageEcosystemAdapter;
538
+
539
+ if (!ecosystem.list) {
540
+ throw unsupportedOperation(
541
+ "listing"
542
+ );
543
+ }
544
+
545
+ return ecosystem.list(
546
+ options
547
+ );
548
+ }
549
+
550
+ export async function copyStorageObject(
551
+ storage: StorageAdapter,
552
+ sourceKey: string,
553
+ destinationKey: string,
554
+ options: StorageCopyOptions = {}
555
+ ): Promise<StorageObjectMetadata> {
556
+ const ecosystem =
557
+ storage as StorageEcosystemAdapter;
558
+
559
+ if (ecosystem.copy) {
560
+ return ecosystem.copy(
561
+ sourceKey,
562
+ destinationKey,
563
+ options
564
+ );
565
+ }
566
+
567
+ return copyStorageObjectFallback(
568
+ ecosystem,
569
+ sourceKey,
570
+ destinationKey,
571
+ options
572
+ );
573
+ }
574
+
575
+ export async function moveStorageObject(
576
+ storage: StorageAdapter,
577
+ sourceKey: string,
578
+ destinationKey: string,
579
+ options: StorageCopyOptions = {}
580
+ ): Promise<StorageObjectMetadata> {
581
+ const ecosystem =
582
+ storage as StorageEcosystemAdapter;
583
+
584
+ if (ecosystem.move) {
585
+ return ecosystem.move(
586
+ sourceKey,
587
+ destinationKey,
588
+ options
589
+ );
590
+ }
591
+
592
+ const source =
593
+ normalizeStorageKey(
594
+ sourceKey
595
+ );
596
+ const destination =
597
+ normalizeStorageKey(
598
+ destinationKey
599
+ );
600
+
601
+ if (source === destination) {
602
+ return requireStorageObject(
603
+ storage,
604
+ source
605
+ );
606
+ }
607
+
608
+ const stored =
609
+ await copyStorageObjectFallback(
610
+ ecosystem,
611
+ source,
612
+ destination,
613
+ options
614
+ );
615
+
616
+ await storage.delete(
617
+ source
618
+ );
619
+
620
+ return stored;
621
+ }
622
+
623
+ export async function deleteStorageObjects(
624
+ storage: StorageAdapter,
625
+ keys: readonly string[]
626
+ ): Promise<StorageDeleteManyResult> {
627
+ const ecosystem =
628
+ storage as StorageEcosystemAdapter;
629
+
630
+ if (ecosystem.deleteMany) {
631
+ return ecosystem.deleteMany(
632
+ keys
633
+ );
634
+ }
635
+
636
+ return deleteStorageObjectsFallback(
637
+ ecosystem,
638
+ keys
639
+ );
640
+ }
641
+
642
+ export async function getStorageMetadata(
643
+ storage: StorageAdapter,
644
+ key: string
645
+ ): Promise<StorageUserMetadata> {
646
+ const ecosystem =
647
+ storage as StorageEcosystemAdapter;
648
+
649
+ if (!ecosystem.getMetadata) {
650
+ throw unsupportedOperation(
651
+ "metadata reads"
652
+ );
653
+ }
654
+
655
+ return ecosystem.getMetadata(
656
+ key
657
+ );
658
+ }
659
+
660
+ export async function setStorageMetadata(
661
+ storage: StorageAdapter,
662
+ key: string,
663
+ metadata: StorageUserMetadata
664
+ ): Promise<StorageUserMetadata> {
665
+ const ecosystem =
666
+ storage as StorageEcosystemAdapter;
667
+
668
+ if (!ecosystem.setMetadata) {
669
+ throw unsupportedOperation(
670
+ "metadata writes"
671
+ );
672
+ }
673
+
674
+ return ecosystem.setMetadata(
675
+ key,
676
+ metadata
677
+ );
678
+ }
679
+
680
+ export async function createStorageSignedReadUrl(
681
+ storage: StorageAdapter,
682
+ key: string,
683
+ options: StorageSignedUrlOptions = {}
684
+ ): Promise<string> {
685
+ const ecosystem =
686
+ storage as StorageEcosystemAdapter;
687
+
688
+ if (!ecosystem.createSignedReadUrl) {
689
+ throw unsupportedOperation(
690
+ "signed read URLs"
691
+ );
692
+ }
693
+
694
+ return ecosystem.createSignedReadUrl(
695
+ key,
696
+ options
697
+ );
698
+ }
699
+
700
+ export async function createStorageSignedWriteUrl(
701
+ storage: StorageAdapter,
702
+ key: string,
703
+ options: StorageSignedWriteUrlOptions = {}
704
+ ): Promise<string> {
705
+ const ecosystem =
706
+ storage as StorageEcosystemAdapter;
707
+
708
+ if (!ecosystem.createSignedWriteUrl) {
709
+ throw unsupportedOperation(
710
+ "signed write URLs"
711
+ );
712
+ }
713
+
714
+ return ecosystem.createSignedWriteUrl(
715
+ key,
716
+ options
717
+ );
718
+ }
719
+
720
+ export function normalizeStorageUserMetadata(
721
+ metadata:
722
+ StorageUserMetadata |
723
+ undefined
724
+ ): StorageUserMetadata {
725
+ if (metadata === undefined) {
726
+ return {};
727
+ }
728
+
729
+ if (
730
+ metadata === null ||
731
+ typeof metadata !==
732
+ "object" ||
733
+ Array.isArray(
734
+ metadata
735
+ )
736
+ ) {
737
+ throw new StorageEcosystemError(
738
+ "INVALID_METADATA",
739
+ "BCP Framework: storage metadata must be a string-to-string object."
740
+ );
741
+ }
742
+
743
+ const normalized:
744
+ StorageUserMetadata = {};
745
+
746
+ for (
747
+ const [rawKey, rawValue]
748
+ of Object.entries(
749
+ metadata
750
+ )
751
+ ) {
752
+ if (
753
+ typeof rawValue !==
754
+ "string"
755
+ ) {
756
+ throw new StorageEcosystemError(
757
+ "INVALID_METADATA",
758
+ `BCP Framework: storage metadata value for "${rawKey}" must be a string.`
759
+ );
760
+ }
761
+
762
+ const key =
763
+ rawKey
764
+ .trim()
765
+ .toLowerCase();
766
+ const value =
767
+ rawValue.trim();
768
+
769
+ if (
770
+ key.length === 0 ||
771
+ !/^[a-z0-9][a-z0-9_.-]{0,127}$/.test(
772
+ key
773
+ ) ||
774
+ key === "bcp_sha256" ||
775
+ /[\r\n\u0000]/.test(
776
+ value
777
+ )
778
+ ) {
779
+ throw new StorageEcosystemError(
780
+ "INVALID_METADATA",
781
+ `BCP Framework: invalid storage metadata entry "${rawKey}".`
782
+ );
783
+ }
784
+
785
+ normalized[
786
+ key
787
+ ] = value;
788
+ }
789
+
790
+ return normalized;
791
+ }
792
+
793
+ export function normalizeStorageListPrefix(
794
+ value: string | undefined
795
+ ): string {
796
+ if (value === undefined) {
797
+ return "";
798
+ }
799
+
800
+ const normalized =
801
+ value
802
+ .trim()
803
+ .replace(
804
+ /\\/g,
805
+ "/"
806
+ )
807
+ .replace(
808
+ /^\/+/,
809
+ ""
810
+ );
811
+
812
+ if (normalized.length === 0) {
813
+ return "";
814
+ }
815
+
816
+ const hasTrailingSlash =
817
+ normalized.endsWith(
818
+ "/"
819
+ );
820
+ const withoutTrailingSlash =
821
+ normalized.replace(
822
+ /\/+$/,
823
+ ""
824
+ );
825
+ const safe =
826
+ normalizeStorageKey(
827
+ withoutTrailingSlash
828
+ );
829
+
830
+ return hasTrailingSlash
831
+ ? `${safe}/`
832
+ : safe;
833
+ }
834
+
835
+ export function normalizeStorageListLimit(
836
+ value: number | undefined
837
+ ): number {
838
+ if (value === undefined) {
839
+ return DEFAULT_LIST_LIMIT;
840
+ }
841
+
842
+ if (
843
+ !Number.isSafeInteger(
844
+ value
845
+ ) ||
846
+ value < 1 ||
847
+ value > MAX_LIST_LIMIT
848
+ ) {
849
+ throw new TypeError(
850
+ `BCP Framework: storage list limit must be an integer between 1 and ${MAX_LIST_LIMIT}.`
851
+ );
852
+ }
853
+
854
+ return value;
855
+ }
856
+
857
+ export function normalizeStorageSignedUrlExpiry(
858
+ value: number | undefined
859
+ ): number {
860
+ if (value === undefined) {
861
+ return 15 * 60;
862
+ }
863
+
864
+ if (
865
+ !Number.isSafeInteger(
866
+ value
867
+ ) ||
868
+ value < 1 ||
869
+ value > 7 * 24 * 60 * 60
870
+ ) {
871
+ throw new TypeError(
872
+ "BCP Framework: signed URL expiresIn must be an integer between 1 and 604800 seconds."
873
+ );
874
+ }
875
+
876
+ return value;
877
+ }
878
+
879
+ async function copyStorageObjectFallback(
880
+ storage: StorageEcosystemAdapter,
881
+ sourceKey: string,
882
+ destinationKey: string,
883
+ options: StorageCopyOptions
884
+ ): Promise<StorageObjectMetadata> {
885
+ const source =
886
+ normalizeStorageKey(
887
+ sourceKey
888
+ );
889
+ const destination =
890
+ normalizeStorageKey(
891
+ destinationKey
892
+ );
893
+ const sourceMetadata =
894
+ await requireStorageObject(
895
+ storage,
896
+ source
897
+ );
898
+
899
+ if (source === destination) {
900
+ return sourceMetadata;
901
+ }
902
+
903
+ if (
904
+ !options.overwrite &&
905
+ await storage.exists(
906
+ destination
907
+ )
908
+ ) {
909
+ throw new StorageError(
910
+ "OBJECT_EXISTS",
911
+ `BCP Framework: storage object "${destination}" already exists.`,
912
+ 409
913
+ );
914
+ }
915
+
916
+ const userMetadata =
917
+ storage.getMetadata
918
+ ? await storage.getMetadata(
919
+ source
920
+ )
921
+ : {};
922
+ const stream =
923
+ await readStorageStream(
924
+ storage,
925
+ source
926
+ );
927
+ const stored =
928
+ await putStorageStream(
929
+ storage,
930
+ destination,
931
+ stream,
932
+ {
933
+ overwrite:
934
+ options.overwrite,
935
+ contentType:
936
+ sourceMetadata.contentType,
937
+ }
938
+ );
939
+
940
+ if (
941
+ storage.setMetadata &&
942
+ Object.keys(
943
+ userMetadata
944
+ ).length > 0
945
+ ) {
946
+ await storage.setMetadata(
947
+ destination,
948
+ userMetadata
949
+ );
950
+ }
951
+
952
+ return stored;
953
+ }
954
+
955
+ async function deleteStorageObjectsFallback(
956
+ storage: StorageAdapter,
957
+ keys: readonly string[]
958
+ ): Promise<StorageDeleteManyResult> {
959
+ const deleted:
960
+ string[] = [];
961
+ const missing:
962
+ string[] = [];
963
+ const failed:
964
+ StorageDeleteManyFailure[] = [];
965
+ const uniqueKeys =
966
+ [
967
+ ...new Set(
968
+ keys.map(
969
+ (key) =>
970
+ normalizeStorageKey(
971
+ key
972
+ )
973
+ )
974
+ ),
975
+ ];
976
+
977
+ for (const key of uniqueKeys) {
978
+ try {
979
+ if (
980
+ await storage.delete(
981
+ key
982
+ )
983
+ ) {
984
+ deleted.push(
985
+ key
986
+ );
987
+ } else {
988
+ missing.push(
989
+ key
990
+ );
991
+ }
992
+ } catch (error) {
993
+ failed.push({
994
+ key,
995
+ message:
996
+ error instanceof Error
997
+ ? error.message
998
+ : String(
999
+ error
1000
+ ),
1001
+ });
1002
+ }
1003
+ }
1004
+
1005
+ return {
1006
+ deleted,
1007
+ missing,
1008
+ failed,
1009
+ };
1010
+ }
1011
+
1012
+ async function requireStorageObject(
1013
+ storage: StorageAdapter,
1014
+ key: string
1015
+ ): Promise<StorageObjectMetadata> {
1016
+ const metadata =
1017
+ await storage.stat(
1018
+ key
1019
+ );
1020
+
1021
+ if (!metadata) {
1022
+ throw new StorageError(
1023
+ "OBJECT_NOT_FOUND",
1024
+ `BCP Framework: storage object "${key}" was not found.`,
1025
+ 404
1026
+ );
1027
+ }
1028
+
1029
+ return metadata;
1030
+ }
1031
+
1032
+ async function collectLocalObjectKeys(
1033
+ rootDirectory: string
1034
+ ): Promise<string[]> {
1035
+ const keys:
1036
+ string[] = [];
1037
+
1038
+ const visit =
1039
+ async (
1040
+ directory: string,
1041
+ prefix: string
1042
+ ): Promise<void> => {
1043
+ let entries;
1044
+
1045
+ try {
1046
+ entries =
1047
+ await readdir(
1048
+ directory,
1049
+ {
1050
+ withFileTypes: true,
1051
+ }
1052
+ );
1053
+ } catch (error) {
1054
+ if (
1055
+ isNodeErrorCode(
1056
+ error,
1057
+ "ENOENT"
1058
+ )
1059
+ ) {
1060
+ return;
1061
+ }
1062
+
1063
+ throw error;
1064
+ }
1065
+
1066
+ for (const entry of entries) {
1067
+ if (
1068
+ prefix.length === 0 &&
1069
+ entry.name ===
1070
+ LOCAL_METADATA_DIRECTORY
1071
+ ) {
1072
+ continue;
1073
+ }
1074
+
1075
+ const relative =
1076
+ prefix.length === 0
1077
+ ? entry.name
1078
+ : `${prefix}/${entry.name}`;
1079
+ const fullPath =
1080
+ path.join(
1081
+ directory,
1082
+ entry.name
1083
+ );
1084
+
1085
+ if (entry.isDirectory()) {
1086
+ await visit(
1087
+ fullPath,
1088
+ relative
1089
+ );
1090
+ continue;
1091
+ }
1092
+
1093
+ if (entry.isFile()) {
1094
+ keys.push(
1095
+ normalizeStorageKey(
1096
+ relative
1097
+ )
1098
+ );
1099
+ }
1100
+ }
1101
+ };
1102
+
1103
+ await visit(
1104
+ rootDirectory,
1105
+ ""
1106
+ );
1107
+
1108
+ return keys;
1109
+ }
1110
+
1111
+ async function replaceLocalUserMetadata(
1112
+ metadataDirectory: string,
1113
+ key: string,
1114
+ metadata:
1115
+ StorageUserMetadata |
1116
+ undefined
1117
+ ): Promise<void> {
1118
+ if (metadata === undefined) {
1119
+ await rm(
1120
+ localUserMetadataFile(
1121
+ metadataDirectory,
1122
+ key
1123
+ ),
1124
+ {
1125
+ force: true,
1126
+ }
1127
+ );
1128
+ return;
1129
+ }
1130
+
1131
+ await writeLocalUserMetadata(
1132
+ metadataDirectory,
1133
+ key,
1134
+ normalizeStorageUserMetadata(
1135
+ metadata
1136
+ )
1137
+ );
1138
+ }
1139
+
1140
+ async function writeLocalUserMetadata(
1141
+ metadataDirectory: string,
1142
+ key: string,
1143
+ metadata: StorageUserMetadata
1144
+ ): Promise<void> {
1145
+ await mkdir(
1146
+ metadataDirectory,
1147
+ {
1148
+ recursive: true,
1149
+ }
1150
+ );
1151
+
1152
+ const record:
1153
+ LocalUserMetadataRecord = {
1154
+ version: 1,
1155
+ key,
1156
+ metadata,
1157
+ };
1158
+
1159
+ await writeFile(
1160
+ localUserMetadataFile(
1161
+ metadataDirectory,
1162
+ key
1163
+ ),
1164
+ JSON.stringify(
1165
+ record
1166
+ ),
1167
+ "utf8"
1168
+ );
1169
+ }
1170
+
1171
+ async function readLocalUserMetadata(
1172
+ metadataDirectory: string,
1173
+ key: string
1174
+ ): Promise<StorageUserMetadata> {
1175
+ try {
1176
+ const raw =
1177
+ await readFile(
1178
+ localUserMetadataFile(
1179
+ metadataDirectory,
1180
+ key
1181
+ ),
1182
+ "utf8"
1183
+ );
1184
+ const parsed =
1185
+ JSON.parse(
1186
+ raw
1187
+ ) as Partial<
1188
+ LocalUserMetadataRecord
1189
+ >;
1190
+
1191
+ if (
1192
+ parsed.version !== 1 ||
1193
+ parsed.key !== key ||
1194
+ parsed.metadata === undefined
1195
+ ) {
1196
+ return {};
1197
+ }
1198
+
1199
+ return normalizeStorageUserMetadata(
1200
+ parsed.metadata
1201
+ );
1202
+ } catch (error) {
1203
+ if (
1204
+ isNodeErrorCode(
1205
+ error,
1206
+ "ENOENT"
1207
+ ) ||
1208
+ error instanceof SyntaxError
1209
+ ) {
1210
+ return {};
1211
+ }
1212
+
1213
+ throw error;
1214
+ }
1215
+ }
1216
+
1217
+ function localUserMetadataFile(
1218
+ metadataDirectory: string,
1219
+ key: string
1220
+ ): string {
1221
+ const digest =
1222
+ createHash(
1223
+ "sha256"
1224
+ )
1225
+ .update(
1226
+ key
1227
+ )
1228
+ .digest(
1229
+ "hex"
1230
+ );
1231
+
1232
+ return path.join(
1233
+ metadataDirectory,
1234
+ `${USER_METADATA_PREFIX}${digest}.json`
1235
+ );
1236
+ }
1237
+
1238
+ function encodeLocalCursor(
1239
+ key: string
1240
+ ): string {
1241
+ return Buffer.from(
1242
+ key,
1243
+ "utf8"
1244
+ ).toString(
1245
+ "base64url"
1246
+ );
1247
+ }
1248
+
1249
+ function decodeLocalCursor(
1250
+ cursor: string | undefined
1251
+ ): string | null {
1252
+ if (cursor === undefined) {
1253
+ return null;
1254
+ }
1255
+
1256
+ try {
1257
+ const key =
1258
+ Buffer.from(
1259
+ cursor,
1260
+ "base64url"
1261
+ ).toString(
1262
+ "utf8"
1263
+ );
1264
+
1265
+ if (
1266
+ key.length === 0 ||
1267
+ encodeLocalCursor(
1268
+ key
1269
+ ) !== cursor
1270
+ ) {
1271
+ throw new Error(
1272
+ "invalid cursor"
1273
+ );
1274
+ }
1275
+
1276
+ return normalizeStorageKey(
1277
+ key
1278
+ );
1279
+ } catch {
1280
+ throw new StorageEcosystemError(
1281
+ "INVALID_CURSOR",
1282
+ "BCP Framework: invalid storage listing cursor."
1283
+ );
1284
+ }
1285
+ }
1286
+
1287
+ function unsupportedOperation(
1288
+ operation: string
1289
+ ): StorageEcosystemError {
1290
+ return new StorageEcosystemError(
1291
+ "UNSUPPORTED_OPERATION",
1292
+ `BCP Framework: this storage adapter does not support ${operation}.`,
1293
+ 501
1294
+ );
1295
+ }
1296
+
1297
+ function assertNonEmptyString(
1298
+ value: string,
1299
+ label: string
1300
+ ): string {
1301
+ if (
1302
+ typeof value !== "string" ||
1303
+ value.trim().length === 0
1304
+ ) {
1305
+ throw new TypeError(
1306
+ `BCP Framework: storage ${label} must be a non-empty string.`
1307
+ );
1308
+ }
1309
+
1310
+ return value.trim();
1311
+ }
1312
+
1313
+ function isNodeErrorCode(
1314
+ error: unknown,
1315
+ code: string
1316
+ ): boolean {
1317
+ return (
1318
+ error instanceof Error &&
1319
+ "code" in error &&
1320
+ (
1321
+ error as Error & {
1322
+ code?: string;
1323
+ }
1324
+ ).code === code
1325
+ );
1326
+ }