@opengeni/storage 0.2.75 → 0.2.93

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 = {
@@ -42,10 +52,14 @@ export type ObjectStorage = {
42
52
  contentType: string;
43
53
  sha256?: string | null;
44
54
  expiresInSeconds?: number;
55
+ /** Select the network endpoint embedded in the signed URL. */
56
+ audience?: "public" | "sandbox";
45
57
  }) => Promise<{ url: string; requiredHeaders: Record<string, string>; expiresAt: Date }>;
46
58
  createGetUrl: (args: {
47
59
  key: string;
48
60
  expiresInSeconds?: number;
61
+ /** Select the network endpoint embedded in the signed URL. */
62
+ audience?: "public" | "sandbox";
49
63
  }) => Promise<{ url: string; expiresAt: Date }>;
50
64
  headFile: (file: FileAsset) => Promise<ObjectHead>;
51
65
  /** Check provider existence without downloading object bytes. */
@@ -61,6 +75,14 @@ export type ObjectStorage = {
61
75
  ) => Promise<Uint8Array | null>;
62
76
  /** Fetch an object by raw storage key (not a tracked FileAsset). Returns null on 404/missing. */
63
77
  getObjectBytes: (key: string) => Promise<{ bytes: Uint8Array; contentType?: string } | null>;
78
+ /** Provider-versioned raw-key primitives used by bounded immutable adapters. */
79
+ headObject?: (key: string) => Promise<ObjectHead | null>;
80
+ getObjectRange?: (args: {
81
+ key: string;
82
+ start: number;
83
+ endInclusive: number;
84
+ expectedVersionToken: string;
85
+ }) => Promise<{ bytes: Uint8Array; versionToken: string } | null>;
64
86
  /**
65
87
  * SERVER-SIDE authenticated direct PUT (no presign + browser fetch). For an
66
88
  * in-process upload from a trusted holder of the storage credentials (e.g. the
@@ -79,6 +101,25 @@ export type ObjectStorage = {
79
101
  body: Uint8Array;
80
102
  sha256?: string | null;
81
103
  }) => Promise<void>;
104
+ /** Atomic create-only raw PUT. Returns false when the key already exists. */
105
+ putObjectIfAbsent?: (args: {
106
+ key: string;
107
+ contentType: string;
108
+ body: Uint8Array;
109
+ sha256: string;
110
+ }) => Promise<boolean>;
111
+ /**
112
+ * Atomic create-only raw upload from a bounded asynchronous byte stream.
113
+ * Providers may buffer a small fixed number of chunks, never the whole body.
114
+ */
115
+ putObjectStreamIfAbsent?: (args: {
116
+ key: string;
117
+ contentType: string;
118
+ chunks: AsyncIterable<Uint8Array>;
119
+ byteSize: number;
120
+ sha256: string;
121
+ signal?: AbortSignal;
122
+ }) => Promise<boolean>;
82
123
  /**
83
124
  * SERVER-SIDE authenticated delete of a single object by raw storage key.
84
125
  * Idempotent: a missing key is a no-op (S3/GCS/Azure delete-by-key does not
@@ -126,6 +167,12 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
126
167
  ...sharedClientConfig,
127
168
  ...(settings.objectStorageEndpoint ? { endpoint: settings.objectStorageEndpoint } : {}),
128
169
  });
170
+ const sandboxPresignClient = settings.objectStorageSandboxEndpoint
171
+ ? new S3Client({
172
+ ...sharedClientConfig,
173
+ endpoint: settings.objectStorageSandboxEndpoint,
174
+ })
175
+ : presignClient;
129
176
  const requestClient = settings.objectStorageInternalEndpoint
130
177
  ? new S3Client({
131
178
  ...sharedClientConfig,
@@ -148,7 +195,13 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
148
195
  Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,
149
196
  });
150
197
  return {
151
- url: await getSignedUrl(presignClient, command, { expiresIn }),
198
+ url: await getSignedUrl(
199
+ args.audience === "sandbox" ? sandboxPresignClient : presignClient,
200
+ command,
201
+ {
202
+ expiresIn,
203
+ },
204
+ ),
152
205
  requiredHeaders,
153
206
  expiresAt: new Date(Date.now() + expiresIn * 1000),
154
207
  };
@@ -157,7 +210,7 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
157
210
  const expiresIn = args.expiresInSeconds ?? DOWNLOAD_URL_TTL_SECONDS;
158
211
  return {
159
212
  url: await getSignedUrl(
160
- presignClient,
213
+ args.audience === "sandbox" ? sandboxPresignClient : presignClient,
161
214
  new GetObjectCommand({
162
215
  Bucket: settings.objectStorageBucket,
163
216
  Key: args.key,
@@ -182,6 +235,44 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
182
235
  }),
183
236
  );
184
237
  },
238
+ async putObjectIfAbsent(args) {
239
+ try {
240
+ await requestClient.send(
241
+ new PutObjectCommand({
242
+ Bucket: settings.objectStorageBucket,
243
+ Key: args.key,
244
+ ContentType: args.contentType,
245
+ Body: args.body,
246
+ Metadata: { sha256: args.sha256 },
247
+ IfNoneMatch: "*",
248
+ }),
249
+ );
250
+ return true;
251
+ } catch (error) {
252
+ if (isS3VersionMismatch(error)) return false;
253
+ throw error;
254
+ }
255
+ },
256
+ async putObjectStreamIfAbsent(args) {
257
+ try {
258
+ await requestClient.send(
259
+ new PutObjectCommand({
260
+ Bucket: settings.objectStorageBucket,
261
+ Key: args.key,
262
+ ContentType: args.contentType,
263
+ ContentLength: args.byteSize,
264
+ Body: Readable.from(args.chunks),
265
+ Metadata: { sha256: args.sha256 },
266
+ IfNoneMatch: "*",
267
+ }),
268
+ args.signal ? { abortSignal: args.signal } : undefined,
269
+ );
270
+ return true;
271
+ } catch (error) {
272
+ if (isS3VersionMismatch(error)) return false;
273
+ throw error;
274
+ }
275
+ },
185
276
  async headFile(file) {
186
277
  const head = await requestClient.send(
187
278
  new HeadObjectCommand({
@@ -234,6 +325,43 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
234
325
  throw error;
235
326
  }
236
327
  },
328
+ async headObject(key) {
329
+ try {
330
+ const head = await requestClient.send(
331
+ new HeadObjectCommand({ Bucket: settings.objectStorageBucket, Key: key }),
332
+ );
333
+ return objectHead({
334
+ contentLength: head.ContentLength,
335
+ contentType: head.ContentType,
336
+ metadata: head.Metadata,
337
+ versionToken: head.ETag,
338
+ });
339
+ } catch (error) {
340
+ if (isS3NotFound(error)) return null;
341
+ throw error;
342
+ }
343
+ },
344
+ async getObjectRange(args) {
345
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
346
+ try {
347
+ const result = await requestClient.send(
348
+ new GetObjectCommand({
349
+ Bucket: settings.objectStorageBucket,
350
+ Key: args.key,
351
+ Range: `bytes=${args.start}-${args.endInclusive}`,
352
+ IfMatch: args.expectedVersionToken,
353
+ }),
354
+ );
355
+ if (!result.ETag || result.ETag !== args.expectedVersionToken) return null;
356
+ return {
357
+ bytes: await s3BodyToBoundedBytes(result.Body, args.key, length),
358
+ versionToken: result.ETag,
359
+ };
360
+ } catch (error) {
361
+ if (isS3NotFound(error) || isS3VersionMismatch(error)) return null;
362
+ throw error;
363
+ }
364
+ },
237
365
  async getObjectBytes(key) {
238
366
  try {
239
367
  const result = await requestClient.send(
@@ -319,6 +447,15 @@ function isS3NotFound(error: unknown): boolean {
319
447
  return metadata?.httpStatusCode === 404;
320
448
  }
321
449
 
450
+ function isS3VersionMismatch(error: unknown): boolean {
451
+ if (!error || typeof error !== "object") return false;
452
+ const metadata =
453
+ "$metadata" in error
454
+ ? (error as { $metadata?: { httpStatusCode?: number } }).$metadata
455
+ : undefined;
456
+ return metadata?.httpStatusCode === 412;
457
+ }
458
+
322
459
  function createGcsObjectStorage(settings: Settings): ObjectStorage {
323
460
  const client = new GcsClient(gcsClientOptions(settings));
324
461
  const bucket = client.bucket(settings.objectStorageBucket);
@@ -365,6 +502,39 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
365
502
  ...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),
366
503
  });
367
504
  },
505
+ async putObjectIfAbsent(args) {
506
+ try {
507
+ await bucket.file(args.key).save(Buffer.from(args.body), {
508
+ contentType: args.contentType,
509
+ metadata: { metadata: { sha256: args.sha256 } },
510
+ preconditionOpts: { ifGenerationMatch: 0 },
511
+ });
512
+ return true;
513
+ } catch (error) {
514
+ if (isGcsVersionMismatch(error)) return false;
515
+ throw error;
516
+ }
517
+ },
518
+ async putObjectStreamIfAbsent(args) {
519
+ const destination = bucket.file(args.key).createWriteStream({
520
+ resumable: false,
521
+ contentType: args.contentType,
522
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
523
+ metadata: { metadata: { sha256: args.sha256 } },
524
+ preconditionOpts: { ifGenerationMatch: 0 },
525
+ });
526
+ try {
527
+ if (args.signal) {
528
+ await pipeline(Readable.from(args.chunks), destination, { signal: args.signal });
529
+ } else {
530
+ await pipeline(Readable.from(args.chunks), destination);
531
+ }
532
+ return true;
533
+ } catch (error) {
534
+ if (isGcsVersionMismatch(error)) return false;
535
+ throw error;
536
+ }
537
+ },
368
538
  async headFile(file) {
369
539
  const [metadata] = await bucket.file(file.objectKey).getMetadata();
370
540
  return objectHead({
@@ -398,6 +568,35 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
398
568
  throw error;
399
569
  }
400
570
  },
571
+ async headObject(key) {
572
+ try {
573
+ const [metadata] = await bucket.file(key).getMetadata();
574
+ return objectHead({
575
+ contentLength: parseContentLength(metadata.size),
576
+ contentType: metadata.contentType,
577
+ metadata: stringMetadata(metadata.metadata),
578
+ versionToken: metadata.generation === undefined ? undefined : String(metadata.generation),
579
+ });
580
+ } catch (error) {
581
+ if (isGcsNotFound(error)) return null;
582
+ throw error;
583
+ }
584
+ },
585
+ async getObjectRange(args) {
586
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
587
+ try {
588
+ const [bytes] = await bucket
589
+ .file(args.key, { generation: args.expectedVersionToken })
590
+ .download({ start: args.start, end: args.endInclusive });
591
+ return {
592
+ bytes: exactRangeBytes(bytes, args.key, length),
593
+ versionToken: args.expectedVersionToken,
594
+ };
595
+ } catch (error) {
596
+ if (isGcsNotFound(error) || isGcsVersionMismatch(error)) return null;
597
+ throw error;
598
+ }
599
+ },
401
600
  async getObjectBytes(key) {
402
601
  try {
403
602
  const [bytes] = await bucket.file(key).download();
@@ -420,6 +619,10 @@ function isGcsNotFound(error: unknown): boolean {
420
619
  return Boolean(error) && typeof error === "object" && (error as { code?: unknown }).code === 404;
421
620
  }
422
621
 
622
+ function isGcsVersionMismatch(error: unknown): boolean {
623
+ return Boolean(error) && typeof error === "object" && (error as { code?: unknown }).code === 412;
624
+ }
625
+
423
626
  function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null {
424
627
  const sharedKey = azureSharedKeyCredential(settings);
425
628
  const requestServiceClient = settings.objectStorageAzureConnectionString
@@ -490,6 +693,41 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
490
693
  ...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
491
694
  });
492
695
  },
696
+ async putObjectIfAbsent(args) {
697
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
698
+ const body = Buffer.from(args.body);
699
+ try {
700
+ await blobClient.upload(body, body.byteLength, {
701
+ blobHTTPHeaders: { blobContentType: args.contentType },
702
+ metadata: { sha256: args.sha256 },
703
+ conditions: { ifNoneMatch: "*" },
704
+ });
705
+ return true;
706
+ } catch (error) {
707
+ if (isAzureVersionMismatch(error)) return false;
708
+ throw error;
709
+ }
710
+ },
711
+ async putObjectStreamIfAbsent(args) {
712
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
713
+ try {
714
+ await blobClient.uploadStream(
715
+ Readable.from(args.chunks),
716
+ INTERNAL_STREAM_BUFFER_BYTES,
717
+ INTERNAL_STREAM_CONCURRENCY,
718
+ {
719
+ blobHTTPHeaders: { blobContentType: args.contentType },
720
+ metadata: { sha256: args.sha256 },
721
+ conditions: { ifNoneMatch: "*" },
722
+ ...(args.signal ? { abortSignal: args.signal } : {}),
723
+ },
724
+ );
725
+ return true;
726
+ } catch (error) {
727
+ if (isAzureVersionMismatch(error)) return false;
728
+ throw error;
729
+ }
730
+ },
493
731
  async headFile(file) {
494
732
  return azureHeadToObjectHead(
495
733
  await requestContainerClient.getBlobClient(file.objectKey).getProperties(),
@@ -522,6 +760,36 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
522
760
  throw error;
523
761
  }
524
762
  },
763
+ async headObject(key) {
764
+ try {
765
+ const properties = await requestContainerClient.getBlobClient(key).getProperties();
766
+ return objectHead({
767
+ contentLength: properties.contentLength,
768
+ contentType: properties.contentType,
769
+ metadata: properties.metadata,
770
+ versionToken: properties.etag,
771
+ });
772
+ } catch (error) {
773
+ if (isAzureNotFound(error)) return null;
774
+ throw error;
775
+ }
776
+ },
777
+ async getObjectRange(args) {
778
+ const length = assertRawObjectByteRange(args.key, args.start, args.endInclusive);
779
+ try {
780
+ const download = await requestContainerClient
781
+ .getBlobClient(args.key)
782
+ .download(args.start, length, { conditions: { ifMatch: args.expectedVersionToken } });
783
+ if (!download.etag || download.etag !== args.expectedVersionToken) return null;
784
+ return {
785
+ bytes: await azureDownloadToBoundedBytes(download, args.key, length),
786
+ versionToken: download.etag,
787
+ };
788
+ } catch (error) {
789
+ if (isAzureNotFound(error) || isAzureVersionMismatch(error)) return null;
790
+ throw error;
791
+ }
792
+ },
525
793
  async getObjectBytes(key) {
526
794
  try {
527
795
  const download = await requestContainerClient.getBlobClient(key).download();
@@ -549,6 +817,14 @@ function isAzureNotFound(error: unknown): boolean {
549
817
  );
550
818
  }
551
819
 
820
+ function isAzureVersionMismatch(error: unknown): boolean {
821
+ return (
822
+ Boolean(error) &&
823
+ typeof error === "object" &&
824
+ (error as { statusCode?: unknown }).statusCode === 412
825
+ );
826
+ }
827
+
552
828
  function azureSharedKeyCredential(settings: Settings): StorageSharedKeyCredential {
553
829
  if (settings.objectStorageAzureConnectionString) {
554
830
  const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
@@ -712,6 +988,25 @@ function assertFileByteRange(
712
988
  return length;
713
989
  }
714
990
 
991
+ function assertRawObjectByteRange(key: string, start: number, endInclusive: number): number {
992
+ if (
993
+ typeof key !== "string" ||
994
+ key.length < 1 ||
995
+ key.length > 2048 ||
996
+ !Number.isSafeInteger(start) ||
997
+ !Number.isSafeInteger(endInclusive) ||
998
+ start < 0 ||
999
+ endInclusive < start
1000
+ ) {
1001
+ throw new RangeError("Invalid raw object byte range");
1002
+ }
1003
+ const length = endInclusive - start + 1;
1004
+ if (length > RETAINED_OUTPUT_MAX_PAGE_BYTES) {
1005
+ throw new RangeError("Raw object byte range exceeds the bounded page limit");
1006
+ }
1007
+ return length;
1008
+ }
1009
+
715
1010
  function exactRangeBytes(bytes: Uint8Array, objectKey: string, expectedBytes: number): Uint8Array {
716
1011
  if (bytes.byteLength !== expectedBytes) {
717
1012
  throw new Error(
@@ -725,11 +1020,13 @@ function objectHead(input: {
725
1020
  contentLength?: number | undefined;
726
1021
  contentType?: string | undefined;
727
1022
  metadata?: Record<string, string> | undefined;
1023
+ versionToken?: string | undefined;
728
1024
  }): ObjectHead {
729
1025
  return {
730
1026
  ...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
731
1027
  ...(input.contentType !== undefined ? { ContentType: input.contentType } : {}),
732
1028
  ...(input.metadata !== undefined ? { Metadata: input.metadata } : {}),
1029
+ ...(input.versionToken !== undefined ? { VersionToken: input.versionToken } : {}),
733
1030
  };
734
1031
  }
735
1032