@opengeni/storage 0.2.68 → 0.2.87

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.
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import { RETAINED_OUTPUT_MAX_PAGE_BYTES, type FileAsset } from "@opengeni/contracts";
3
+ import { Readable } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
3
5
  import {
4
6
  BlobSASPermissions,
5
7
  BlobServiceClient,
@@ -23,14 +25,22 @@ import {
23
25
  type StorageOptions,
24
26
  } from "@google-cloud/storage";
25
27
 
28
+ export * from "./bounded-object-read";
29
+ export * from "./bounded-object-write";
30
+ export * from "./object-storage-bounded";
31
+
26
32
  export const MAX_SINGLE_PUT_SIZE_BYTES = 5_000_000_000;
27
33
  export const UPLOAD_URL_TTL_SECONDS = 15 * 60;
28
34
  export const DOWNLOAD_URL_TTL_SECONDS = 5 * 60;
35
+ const INTERNAL_STREAM_BUFFER_BYTES = 1024 * 1024;
36
+ const INTERNAL_STREAM_CONCURRENCY = 2;
29
37
 
30
38
  export type ObjectHead = {
31
39
  ContentLength?: number;
32
40
  ContentType?: string;
33
41
  Metadata?: Record<string, string>;
42
+ /** Opaque provider generation/etag used only for conditional internal reads. */
43
+ VersionToken?: string;
34
44
  };
35
45
 
36
46
  export type ObjectStorage = {
@@ -61,6 +71,14 @@ export type ObjectStorage = {
61
71
  ) => Promise<Uint8Array | null>;
62
72
  /** Fetch an object by raw storage key (not a tracked FileAsset). Returns null on 404/missing. */
63
73
  getObjectBytes: (key: string) => Promise<{ bytes: Uint8Array; contentType?: string } | null>;
74
+ /** Provider-versioned raw-key primitives used by bounded immutable adapters. */
75
+ headObject?: (key: string) => Promise<ObjectHead | null>;
76
+ getObjectRange?: (args: {
77
+ key: string;
78
+ start: number;
79
+ endInclusive: number;
80
+ expectedVersionToken: string;
81
+ }) => Promise<{ bytes: Uint8Array; versionToken: string } | null>;
64
82
  /**
65
83
  * SERVER-SIDE authenticated direct PUT (no presign + browser fetch). For an
66
84
  * in-process upload from a trusted holder of the storage credentials (e.g. the
@@ -79,6 +97,25 @@ export type ObjectStorage = {
79
97
  body: Uint8Array;
80
98
  sha256?: string | null;
81
99
  }) => Promise<void>;
100
+ /** Atomic create-only raw PUT. Returns false when the key already exists. */
101
+ putObjectIfAbsent?: (args: {
102
+ key: string;
103
+ contentType: string;
104
+ body: Uint8Array;
105
+ sha256: string;
106
+ }) => Promise<boolean>;
107
+ /**
108
+ * Atomic create-only raw upload from a bounded asynchronous byte stream.
109
+ * Providers may buffer a small fixed number of chunks, never the whole body.
110
+ */
111
+ putObjectStreamIfAbsent?: (args: {
112
+ key: string;
113
+ contentType: string;
114
+ chunks: AsyncIterable<Uint8Array>;
115
+ byteSize: number;
116
+ sha256: string;
117
+ signal?: AbortSignal;
118
+ }) => Promise<boolean>;
82
119
  /**
83
120
  * SERVER-SIDE authenticated delete of a single object by raw storage key.
84
121
  * Idempotent: a missing key is a no-op (S3/GCS/Azure delete-by-key does not
@@ -182,6 +219,44 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
182
219
  }),
183
220
  );
184
221
  },
222
+ async putObjectIfAbsent(args) {
223
+ try {
224
+ await requestClient.send(
225
+ new PutObjectCommand({
226
+ Bucket: settings.objectStorageBucket,
227
+ Key: args.key,
228
+ ContentType: args.contentType,
229
+ Body: args.body,
230
+ Metadata: { sha256: args.sha256 },
231
+ IfNoneMatch: "*",
232
+ }),
233
+ );
234
+ return true;
235
+ } catch (error) {
236
+ if (isS3VersionMismatch(error)) return false;
237
+ throw error;
238
+ }
239
+ },
240
+ async putObjectStreamIfAbsent(args) {
241
+ try {
242
+ await requestClient.send(
243
+ new PutObjectCommand({
244
+ Bucket: settings.objectStorageBucket,
245
+ Key: args.key,
246
+ ContentType: args.contentType,
247
+ ContentLength: args.byteSize,
248
+ Body: Readable.from(args.chunks),
249
+ Metadata: { sha256: args.sha256 },
250
+ IfNoneMatch: "*",
251
+ }),
252
+ args.signal ? { abortSignal: args.signal } : undefined,
253
+ );
254
+ return true;
255
+ } catch (error) {
256
+ if (isS3VersionMismatch(error)) return false;
257
+ throw error;
258
+ }
259
+ },
185
260
  async headFile(file) {
186
261
  const head = await requestClient.send(
187
262
  new HeadObjectCommand({
@@ -234,6 +309,43 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
234
309
  throw error;
235
310
  }
236
311
  },
312
+ async headObject(key) {
313
+ try {
314
+ const head = await requestClient.send(
315
+ new HeadObjectCommand({ Bucket: settings.objectStorageBucket, Key: key }),
316
+ );
317
+ return objectHead({
318
+ contentLength: head.ContentLength,
319
+ contentType: head.ContentType,
320
+ metadata: head.Metadata,
321
+ versionToken: head.ETag,
322
+ });
323
+ } catch (error) {
324
+ if (isS3NotFound(error)) return null;
325
+ throw error;
326
+ }
327
+ },
328
+ async getObjectRange(args) {
329
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
330
+ try {
331
+ const result = await requestClient.send(
332
+ new GetObjectCommand({
333
+ Bucket: settings.objectStorageBucket,
334
+ Key: args.key,
335
+ Range: `bytes=${args.start}-${args.endInclusive}`,
336
+ IfMatch: args.expectedVersionToken,
337
+ }),
338
+ );
339
+ if (!result.ETag || result.ETag !== args.expectedVersionToken) return null;
340
+ return {
341
+ bytes: await s3BodyToBoundedBytes(result.Body, args.key, length),
342
+ versionToken: result.ETag,
343
+ };
344
+ } catch (error) {
345
+ if (isS3NotFound(error) || isS3VersionMismatch(error)) return null;
346
+ throw error;
347
+ }
348
+ },
237
349
  async getObjectBytes(key) {
238
350
  try {
239
351
  const result = await requestClient.send(
@@ -319,6 +431,15 @@ function isS3NotFound(error: unknown): boolean {
319
431
  return metadata?.httpStatusCode === 404;
320
432
  }
321
433
 
434
+ function isS3VersionMismatch(error: unknown): boolean {
435
+ if (!error || typeof error !== "object") return false;
436
+ const metadata =
437
+ "$metadata" in error
438
+ ? (error as { $metadata?: { httpStatusCode?: number } }).$metadata
439
+ : undefined;
440
+ return metadata?.httpStatusCode === 412;
441
+ }
442
+
322
443
  function createGcsObjectStorage(settings: Settings): ObjectStorage {
323
444
  const client = new GcsClient(gcsClientOptions(settings));
324
445
  const bucket = client.bucket(settings.objectStorageBucket);
@@ -365,6 +486,39 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
365
486
  ...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),
366
487
  });
367
488
  },
489
+ async putObjectIfAbsent(args) {
490
+ try {
491
+ await bucket.file(args.key).save(Buffer.from(args.body), {
492
+ contentType: args.contentType,
493
+ metadata: { metadata: { sha256: args.sha256 } },
494
+ preconditionOpts: { ifGenerationMatch: 0 },
495
+ });
496
+ return true;
497
+ } catch (error) {
498
+ if (isGcsVersionMismatch(error)) return false;
499
+ throw error;
500
+ }
501
+ },
502
+ async putObjectStreamIfAbsent(args) {
503
+ const destination = bucket.file(args.key).createWriteStream({
504
+ resumable: false,
505
+ contentType: args.contentType,
506
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
507
+ metadata: { metadata: { sha256: args.sha256 } },
508
+ preconditionOpts: { ifGenerationMatch: 0 },
509
+ });
510
+ try {
511
+ if (args.signal) {
512
+ await pipeline(Readable.from(args.chunks), destination, { signal: args.signal });
513
+ } else {
514
+ await pipeline(Readable.from(args.chunks), destination);
515
+ }
516
+ return true;
517
+ } catch (error) {
518
+ if (isGcsVersionMismatch(error)) return false;
519
+ throw error;
520
+ }
521
+ },
368
522
  async headFile(file) {
369
523
  const [metadata] = await bucket.file(file.objectKey).getMetadata();
370
524
  return objectHead({
@@ -398,6 +552,35 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
398
552
  throw error;
399
553
  }
400
554
  },
555
+ async headObject(key) {
556
+ try {
557
+ const [metadata] = await bucket.file(key).getMetadata();
558
+ return objectHead({
559
+ contentLength: parseContentLength(metadata.size),
560
+ contentType: metadata.contentType,
561
+ metadata: stringMetadata(metadata.metadata),
562
+ versionToken: metadata.generation === undefined ? undefined : String(metadata.generation),
563
+ });
564
+ } catch (error) {
565
+ if (isGcsNotFound(error)) return null;
566
+ throw error;
567
+ }
568
+ },
569
+ async getObjectRange(args) {
570
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
571
+ try {
572
+ const [bytes] = await bucket
573
+ .file(args.key, { generation: args.expectedVersionToken })
574
+ .download({ start: args.start, end: args.endInclusive });
575
+ return {
576
+ bytes: exactRangeBytes(bytes, args.key, length),
577
+ versionToken: args.expectedVersionToken,
578
+ };
579
+ } catch (error) {
580
+ if (isGcsNotFound(error) || isGcsVersionMismatch(error)) return null;
581
+ throw error;
582
+ }
583
+ },
401
584
  async getObjectBytes(key) {
402
585
  try {
403
586
  const [bytes] = await bucket.file(key).download();
@@ -420,12 +603,24 @@ function isGcsNotFound(error: unknown): boolean {
420
603
  return Boolean(error) && typeof error === "object" && (error as { code?: unknown }).code === 404;
421
604
  }
422
605
 
606
+ function isGcsVersionMismatch(error: unknown): boolean {
607
+ return Boolean(error) && typeof error === "object" && (error as { code?: unknown }).code === 412;
608
+ }
609
+
423
610
  function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null {
424
611
  const sharedKey = azureSharedKeyCredential(settings);
425
- const serviceClient = settings.objectStorageAzureConnectionString
612
+ const requestServiceClient = settings.objectStorageAzureConnectionString
426
613
  ? BlobServiceClient.fromConnectionString(settings.objectStorageAzureConnectionString)
427
614
  : new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey);
428
- const containerClient = serviceClient.getContainerClient(settings.objectStorageBucket);
615
+ const presignServiceClient = settings.objectStorageAzureEndpoint
616
+ ? new BlobServiceClient(azureBlobServiceUrl(settings), sharedKey)
617
+ : requestServiceClient;
618
+ const requestContainerClient = requestServiceClient.getContainerClient(
619
+ settings.objectStorageBucket,
620
+ );
621
+ const presignContainerClient = presignServiceClient.getContainerClient(
622
+ settings.objectStorageBucket,
623
+ );
429
624
 
430
625
  return {
431
626
  bucket: settings.objectStorageBucket,
@@ -434,7 +629,7 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
434
629
  async createPutUrl(args) {
435
630
  const expiresIn = args.expiresInSeconds ?? UPLOAD_URL_TTL_SECONDS;
436
631
  const expiresAt = new Date(Date.now() + expiresIn * 1000);
437
- const blobClient = containerClient.getBlockBlobClient(args.key);
632
+ const blobClient = presignContainerClient.getBlockBlobClient(args.key);
438
633
  const sas = generateBlobSASQueryParameters(
439
634
  {
440
635
  containerName: settings.objectStorageBucket,
@@ -458,7 +653,7 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
458
653
  async createGetUrl(args) {
459
654
  const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
460
655
  const expiresAt = new Date(Date.now() + expiresIn * 1000);
461
- const blobClient = containerClient.getBlobClient(args.key);
656
+ const blobClient = presignContainerClient.getBlobClient(args.key);
462
657
  const sas = generateBlobSASQueryParameters(
463
658
  {
464
659
  containerName: settings.objectStorageBucket,
@@ -475,21 +670,56 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
475
670
  },
476
671
  async putObject(args) {
477
672
  // Authenticated in-process upload via the shared-key Azure client (no SAS).
478
- const blobClient = containerClient.getBlockBlobClient(args.key);
673
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
479
674
  const body = Buffer.from(args.body);
480
675
  await blobClient.upload(body, body.byteLength, {
481
676
  blobHTTPHeaders: { blobContentType: args.contentType },
482
677
  ...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
483
678
  });
484
679
  },
680
+ async putObjectIfAbsent(args) {
681
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
682
+ const body = Buffer.from(args.body);
683
+ try {
684
+ await blobClient.upload(body, body.byteLength, {
685
+ blobHTTPHeaders: { blobContentType: args.contentType },
686
+ metadata: { sha256: args.sha256 },
687
+ conditions: { ifNoneMatch: "*" },
688
+ });
689
+ return true;
690
+ } catch (error) {
691
+ if (isAzureVersionMismatch(error)) return false;
692
+ throw error;
693
+ }
694
+ },
695
+ async putObjectStreamIfAbsent(args) {
696
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
697
+ try {
698
+ await blobClient.uploadStream(
699
+ Readable.from(args.chunks),
700
+ INTERNAL_STREAM_BUFFER_BYTES,
701
+ INTERNAL_STREAM_CONCURRENCY,
702
+ {
703
+ blobHTTPHeaders: { blobContentType: args.contentType },
704
+ metadata: { sha256: args.sha256 },
705
+ conditions: { ifNoneMatch: "*" },
706
+ ...(args.signal ? { abortSignal: args.signal } : {}),
707
+ },
708
+ );
709
+ return true;
710
+ } catch (error) {
711
+ if (isAzureVersionMismatch(error)) return false;
712
+ throw error;
713
+ }
714
+ },
485
715
  async headFile(file) {
486
716
  return azureHeadToObjectHead(
487
- await containerClient.getBlobClient(file.objectKey).getProperties(),
717
+ await requestContainerClient.getBlobClient(file.objectKey).getProperties(),
488
718
  );
489
719
  },
490
720
  async fileExists(file) {
491
721
  try {
492
- await containerClient.getBlobClient(file.objectKey).getProperties();
722
+ await requestContainerClient.getBlobClient(file.objectKey).getProperties();
493
723
  return true;
494
724
  } catch (error) {
495
725
  if (isAzureNotFound(error)) return false;
@@ -498,14 +728,14 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
498
728
  },
499
729
  async getFileBytes(file) {
500
730
  return await azureDownloadToBytes(
501
- await containerClient.getBlobClient(file.objectKey).download(),
731
+ await requestContainerClient.getBlobClient(file.objectKey).download(),
502
732
  );
503
733
  },
504
734
  async getFileRange(file, range) {
505
735
  const length = assertFileByteRange(file, range);
506
736
  try {
507
737
  return await azureDownloadToBoundedBytes(
508
- await containerClient.getBlobClient(file.objectKey).download(range.start, length),
738
+ await requestContainerClient.getBlobClient(file.objectKey).download(range.start, length),
509
739
  file.objectKey,
510
740
  length,
511
741
  );
@@ -514,9 +744,39 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
514
744
  throw error;
515
745
  }
516
746
  },
747
+ async headObject(key) {
748
+ try {
749
+ const properties = await requestContainerClient.getBlobClient(key).getProperties();
750
+ return objectHead({
751
+ contentLength: properties.contentLength,
752
+ contentType: properties.contentType,
753
+ metadata: properties.metadata,
754
+ versionToken: properties.etag,
755
+ });
756
+ } catch (error) {
757
+ if (isAzureNotFound(error)) return null;
758
+ throw error;
759
+ }
760
+ },
761
+ async getObjectRange(args) {
762
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
763
+ try {
764
+ const download = await requestContainerClient
765
+ .getBlobClient(args.key)
766
+ .download(args.start, length, { conditions: { ifMatch: args.expectedVersionToken } });
767
+ if (!download.etag || download.etag !== args.expectedVersionToken) return null;
768
+ return {
769
+ bytes: await azureDownloadToBoundedBytes(download, args.key, length),
770
+ versionToken: download.etag,
771
+ };
772
+ } catch (error) {
773
+ if (isAzureNotFound(error) || isAzureVersionMismatch(error)) return null;
774
+ throw error;
775
+ }
776
+ },
517
777
  async getObjectBytes(key) {
518
778
  try {
519
- const download = await containerClient.getBlobClient(key).download();
779
+ const download = await requestContainerClient.getBlobClient(key).download();
520
780
  const bytes = await azureDownloadToBytes(download);
521
781
  return { bytes, ...(download.contentType ? { contentType: download.contentType } : {}) };
522
782
  } catch (error) {
@@ -528,7 +788,7 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
528
788
  },
529
789
  async deleteObject(key) {
530
790
  // deleteIfExists keeps the delete idempotent (a missing blob is a no-op).
531
- await containerClient.getBlockBlobClient(key).deleteIfExists();
791
+ await requestContainerClient.getBlockBlobClient(key).deleteIfExists();
532
792
  },
533
793
  };
534
794
  }
@@ -541,6 +801,14 @@ function isAzureNotFound(error: unknown): boolean {
541
801
  );
542
802
  }
543
803
 
804
+ function isAzureVersionMismatch(error: unknown): boolean {
805
+ return (
806
+ Boolean(error) &&
807
+ typeof error === "object" &&
808
+ (error as { statusCode?: unknown }).statusCode === 412
809
+ );
810
+ }
811
+
544
812
  function azureSharedKeyCredential(settings: Settings): StorageSharedKeyCredential {
545
813
  if (settings.objectStorageAzureConnectionString) {
546
814
  const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
@@ -704,6 +972,25 @@ function assertFileByteRange(
704
972
  return length;
705
973
  }
706
974
 
975
+ function assertRawObjectByteRange(key: string, start: number, endInclusive: number): number {
976
+ if (
977
+ typeof key !== "string" ||
978
+ key.length < 1 ||
979
+ key.length > 2048 ||
980
+ !Number.isSafeInteger(start) ||
981
+ !Number.isSafeInteger(endInclusive) ||
982
+ start < 0 ||
983
+ endInclusive < start
984
+ ) {
985
+ throw new RangeError("Invalid raw object byte range");
986
+ }
987
+ const length = endInclusive - start + 1;
988
+ if (length > RETAINED_OUTPUT_MAX_PAGE_BYTES) {
989
+ throw new RangeError("Raw object byte range exceeds the bounded page limit");
990
+ }
991
+ return length;
992
+ }
993
+
707
994
  function exactRangeBytes(bytes: Uint8Array, objectKey: string, expectedBytes: number): Uint8Array {
708
995
  if (bytes.byteLength !== expectedBytes) {
709
996
  throw new Error(
@@ -717,11 +1004,13 @@ function objectHead(input: {
717
1004
  contentLength?: number | undefined;
718
1005
  contentType?: string | undefined;
719
1006
  metadata?: Record<string, string> | undefined;
1007
+ versionToken?: string | undefined;
720
1008
  }): ObjectHead {
721
1009
  return {
722
1010
  ...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
723
1011
  ...(input.contentType !== undefined ? { ContentType: input.contentType } : {}),
724
1012
  ...(input.metadata !== undefined ? { Metadata: input.metadata } : {}),
1013
+ ...(input.versionToken !== undefined ? { VersionToken: input.versionToken } : {}),
725
1014
  };
726
1015
  }
727
1016