@opengeni/storage 0.2.75 → 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/dist/bounded-object-read.d.ts +76 -0
- package/dist/bounded-object-write.d.ts +53 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +798 -2
- package/dist/index.js.map +1 -1
- package/dist/object-storage-bounded.d.ts +21 -0
- package/package.json +3 -3
- package/src/bounded-object-read.ts +323 -0
- package/src/bounded-object-write.ts +269 -0
- package/src/index.ts +281 -0
- package/src/object-storage-bounded.ts +297 -0
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,6 +603,10 @@ 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
612
|
const requestServiceClient = settings.objectStorageAzureConnectionString
|
|
@@ -490,6 +677,41 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
|
|
|
490
677
|
...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
|
|
491
678
|
});
|
|
492
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
|
+
},
|
|
493
715
|
async headFile(file) {
|
|
494
716
|
return azureHeadToObjectHead(
|
|
495
717
|
await requestContainerClient.getBlobClient(file.objectKey).getProperties(),
|
|
@@ -522,6 +744,36 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
|
|
|
522
744
|
throw error;
|
|
523
745
|
}
|
|
524
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
|
+
},
|
|
525
777
|
async getObjectBytes(key) {
|
|
526
778
|
try {
|
|
527
779
|
const download = await requestContainerClient.getBlobClient(key).download();
|
|
@@ -549,6 +801,14 @@ function isAzureNotFound(error: unknown): boolean {
|
|
|
549
801
|
);
|
|
550
802
|
}
|
|
551
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
|
+
|
|
552
812
|
function azureSharedKeyCredential(settings: Settings): StorageSharedKeyCredential {
|
|
553
813
|
if (settings.objectStorageAzureConnectionString) {
|
|
554
814
|
const parsed = parseConnectionString(settings.objectStorageAzureConnectionString);
|
|
@@ -712,6 +972,25 @@ function assertFileByteRange(
|
|
|
712
972
|
return length;
|
|
713
973
|
}
|
|
714
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
|
+
|
|
715
994
|
function exactRangeBytes(bytes: Uint8Array, objectKey: string, expectedBytes: number): Uint8Array {
|
|
716
995
|
if (bytes.byteLength !== expectedBytes) {
|
|
717
996
|
throw new Error(
|
|
@@ -725,11 +1004,13 @@ function objectHead(input: {
|
|
|
725
1004
|
contentLength?: number | undefined;
|
|
726
1005
|
contentType?: string | undefined;
|
|
727
1006
|
metadata?: Record<string, string> | undefined;
|
|
1007
|
+
versionToken?: string | undefined;
|
|
728
1008
|
}): ObjectHead {
|
|
729
1009
|
return {
|
|
730
1010
|
...(input.contentLength !== undefined ? { ContentLength: input.contentLength } : {}),
|
|
731
1011
|
...(input.contentType !== undefined ? { ContentType: input.contentType } : {}),
|
|
732
1012
|
...(input.metadata !== undefined ? { Metadata: input.metadata } : {}),
|
|
1013
|
+
...(input.versionToken !== undefined ? { VersionToken: input.versionToken } : {}),
|
|
733
1014
|
};
|
|
734
1015
|
}
|
|
735
1016
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmod, mkdtemp, open, rm, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createBoundedObjectReadPort,
|
|
8
|
+
type BoundedObjectReadPort,
|
|
9
|
+
type VersionedRangeObjectBackend,
|
|
10
|
+
} from "./bounded-object-read";
|
|
11
|
+
import {
|
|
12
|
+
createBoundedImmutableObjectWritePort,
|
|
13
|
+
type BoundedImmutableObjectWritePort,
|
|
14
|
+
type ImmutableContentAddressedWriteBackend,
|
|
15
|
+
type ImmutableContentAddressedWriteSession,
|
|
16
|
+
} from "./bounded-object-write";
|
|
17
|
+
import type { ObjectStorage } from "./index";
|
|
18
|
+
|
|
19
|
+
const SNAPSHOT_KEY_PREFIX = "editable-artifacts/snapshots/sha256/";
|
|
20
|
+
|
|
21
|
+
export type ObjectStorageBoundedPorts = Readonly<{
|
|
22
|
+
read: BoundedObjectReadPort;
|
|
23
|
+
write: BoundedImmutableObjectWritePort;
|
|
24
|
+
}>;
|
|
25
|
+
|
|
26
|
+
export type ObjectStorageBoundedPortsOptions = Readonly<{
|
|
27
|
+
/** Static trusted namespace ending in `/sha256/`. */
|
|
28
|
+
keyPrefix?: string;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bounded immutable snapshot ports over the standalone object-storage driver.
|
|
33
|
+
* Reads are provider-version pinned. Writes use a canonical digest key and are
|
|
34
|
+
* independently range-read and hashed by the shared verification layer.
|
|
35
|
+
*
|
|
36
|
+
* Provider uploads and readback are streaming. Staging uses one private local
|
|
37
|
+
* file so content-addressed naming never requires retaining the object in RAM.
|
|
38
|
+
* There is no whole-object fallback when a provider lacks these primitives.
|
|
39
|
+
*/
|
|
40
|
+
export function createObjectStorageBoundedPorts(
|
|
41
|
+
storage: ObjectStorage,
|
|
42
|
+
options: ObjectStorageBoundedPortsOptions = {},
|
|
43
|
+
): ObjectStorageBoundedPorts {
|
|
44
|
+
if (!storage.headObject || !storage.getObjectRange || !storage.putObjectStreamIfAbsent) {
|
|
45
|
+
throw new Error("Object storage lacks streaming immutable create/versioned range primitives");
|
|
46
|
+
}
|
|
47
|
+
const keyPrefix = validateKeyPrefix(options.keyPrefix ?? SNAPSHOT_KEY_PREFIX);
|
|
48
|
+
const referenceHash = (reference: string) => hashForReference(reference, keyPrefix);
|
|
49
|
+
const read = createBoundedObjectReadPort(versionedBackend(storage, referenceHash));
|
|
50
|
+
const write = createBoundedImmutableObjectWritePort({
|
|
51
|
+
backend: immutableWriteBackend(storage, keyPrefix),
|
|
52
|
+
readback: read,
|
|
53
|
+
});
|
|
54
|
+
return Object.freeze({ read, write });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function versionedBackend(
|
|
58
|
+
storage: ObjectStorage,
|
|
59
|
+
hashForReferenceValue: (reference: string) => string,
|
|
60
|
+
): VersionedRangeObjectBackend {
|
|
61
|
+
const headObject = storage.headObject!;
|
|
62
|
+
const getObjectRange = storage.getObjectRange!;
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
async describe(input: Parameters<VersionedRangeObjectBackend["describe"]>[0]) {
|
|
65
|
+
const expectedHash = hashForReferenceValue(input.opaqueReference);
|
|
66
|
+
throwIfAborted(input.signal);
|
|
67
|
+
const head = await headObject(input.opaqueReference);
|
|
68
|
+
throwIfAborted(input.signal);
|
|
69
|
+
if (!head) return null;
|
|
70
|
+
if (
|
|
71
|
+
!Number.isSafeInteger(head.ContentLength) ||
|
|
72
|
+
head.ContentLength! < 0 ||
|
|
73
|
+
typeof head.VersionToken !== "string" ||
|
|
74
|
+
head.VersionToken.length < 1 ||
|
|
75
|
+
head.VersionToken.length > 2048 ||
|
|
76
|
+
head.Metadata?.sha256 !== expectedHash
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("Immutable object metadata is invalid");
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
byteSize: head.ContentLength!,
|
|
82
|
+
versionToken: head.VersionToken,
|
|
83
|
+
immutableReference: true as const,
|
|
84
|
+
...(head.ContentType ? { contentType: head.ContentType } : {}),
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
async readRange(input: Parameters<VersionedRangeObjectBackend["readRange"]>[0]) {
|
|
88
|
+
hashForReferenceValue(input.opaqueReference);
|
|
89
|
+
throwIfAborted(input.signal);
|
|
90
|
+
const result = await getObjectRange({
|
|
91
|
+
key: input.opaqueReference,
|
|
92
|
+
start: input.start,
|
|
93
|
+
endInclusive: input.endInclusive,
|
|
94
|
+
expectedVersionToken: input.expectedVersionToken,
|
|
95
|
+
});
|
|
96
|
+
throwIfAborted(input.signal);
|
|
97
|
+
if (!result) return null;
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
bytes: result.bytes.slice(),
|
|
100
|
+
versionToken: result.versionToken,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function immutableWriteBackend(
|
|
107
|
+
storage: ObjectStorage,
|
|
108
|
+
keyPrefix: string,
|
|
109
|
+
): ImmutableContentAddressedWriteBackend {
|
|
110
|
+
return Object.freeze({
|
|
111
|
+
async begin(
|
|
112
|
+
input: Parameters<ImmutableContentAddressedWriteBackend["begin"]>[0],
|
|
113
|
+
): Promise<ImmutableContentAddressedWriteSession> {
|
|
114
|
+
validateContentType(input.contentType);
|
|
115
|
+
throwIfAborted(input.signal);
|
|
116
|
+
const stagingDirectory = await mkdtemp(join(tmpdir(), "opengeni-artifact-write-"));
|
|
117
|
+
const stagingPath = join(stagingDirectory, "payload");
|
|
118
|
+
let handle: FileHandle | null = null;
|
|
119
|
+
try {
|
|
120
|
+
await chmod(stagingDirectory, 0o700);
|
|
121
|
+
throwIfAborted(input.signal);
|
|
122
|
+
handle = await open(stagingPath, "wx", 0o600);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
let byteSize = 0;
|
|
128
|
+
let closed = false;
|
|
129
|
+
let cleaned = false;
|
|
130
|
+
const digest = createHash("sha256");
|
|
131
|
+
const cleanup = async () => {
|
|
132
|
+
if (cleaned) return;
|
|
133
|
+
cleaned = true;
|
|
134
|
+
if (handle) {
|
|
135
|
+
const closing = handle;
|
|
136
|
+
handle = null;
|
|
137
|
+
await closing.close().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
async write(chunk: Uint8Array, signal?: AbortSignal) {
|
|
143
|
+
if (closed) throw new Error("Immutable write session is closed");
|
|
144
|
+
throwIfAborted(signal);
|
|
145
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength === 0) {
|
|
146
|
+
throw new Error("Immutable write chunk is invalid");
|
|
147
|
+
}
|
|
148
|
+
await writeAll(handle!, chunk, byteSize);
|
|
149
|
+
digest.update(chunk);
|
|
150
|
+
byteSize += chunk.byteLength;
|
|
151
|
+
},
|
|
152
|
+
async commit(commit: Parameters<ImmutableContentAddressedWriteSession["commit"]>[0]) {
|
|
153
|
+
if (closed) throw new Error("Immutable write session is closed");
|
|
154
|
+
closed = true;
|
|
155
|
+
try {
|
|
156
|
+
throwIfAborted(commit.signal);
|
|
157
|
+
if (commit.contentType !== input.contentType || commit.byteSize !== byteSize) {
|
|
158
|
+
throw new Error("Immutable write commit metadata changed");
|
|
159
|
+
}
|
|
160
|
+
const contentHash = `sha256:${digest.digest("hex")}`;
|
|
161
|
+
if (contentHash !== commit.contentHash) {
|
|
162
|
+
throw new Error("Immutable write digest changed");
|
|
163
|
+
}
|
|
164
|
+
const opaqueReference = `${keyPrefix}${contentHash.slice("sha256:".length)}`;
|
|
165
|
+
const closing = handle!;
|
|
166
|
+
handle = null;
|
|
167
|
+
await closing.close();
|
|
168
|
+
const existing = await storage.headObject!(opaqueReference);
|
|
169
|
+
if (existing) {
|
|
170
|
+
assertStoredObject(existing, byteSize, contentHash, input.contentType);
|
|
171
|
+
} else {
|
|
172
|
+
await storage.putObjectStreamIfAbsent!({
|
|
173
|
+
key: opaqueReference,
|
|
174
|
+
contentType: input.contentType,
|
|
175
|
+
chunks: fileChunks(stagingPath, byteSize, commit.signal),
|
|
176
|
+
byteSize,
|
|
177
|
+
sha256: contentHash,
|
|
178
|
+
...(commit.signal ? { signal: commit.signal } : {}),
|
|
179
|
+
});
|
|
180
|
+
const stored = await storage.headObject!(opaqueReference);
|
|
181
|
+
if (!stored) throw new Error("Immutable object was not visible after write");
|
|
182
|
+
assertStoredObject(stored, byteSize, contentHash, input.contentType);
|
|
183
|
+
}
|
|
184
|
+
throwIfAborted(commit.signal);
|
|
185
|
+
return Object.freeze({ opaqueReference });
|
|
186
|
+
} finally {
|
|
187
|
+
await cleanup();
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
async abort() {
|
|
191
|
+
closed = true;
|
|
192
|
+
byteSize = 0;
|
|
193
|
+
await cleanup();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertStoredObject(
|
|
201
|
+
head: Awaited<ReturnType<NonNullable<ObjectStorage["headObject"]>>>,
|
|
202
|
+
byteSize: number,
|
|
203
|
+
contentHash: string,
|
|
204
|
+
contentType: string,
|
|
205
|
+
): void {
|
|
206
|
+
if (
|
|
207
|
+
!head ||
|
|
208
|
+
head.ContentLength !== byteSize ||
|
|
209
|
+
head.Metadata?.sha256 !== contentHash ||
|
|
210
|
+
head.ContentType !== contentType ||
|
|
211
|
+
typeof head.VersionToken !== "string" ||
|
|
212
|
+
head.VersionToken.length < 1
|
|
213
|
+
) {
|
|
214
|
+
throw new Error("Immutable content-addressed object conflicts with stored metadata");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hashForReference(reference: string, keyPrefix: string): string {
|
|
219
|
+
const match = new RegExp(`^${escapeRegExp(keyPrefix)}([0-9a-f]{64})$`, "u").exec(reference);
|
|
220
|
+
if (!match) throw new Error("Immutable snapshot reference is malformed");
|
|
221
|
+
return `sha256:${match[1]}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateKeyPrefix(value: string): string {
|
|
225
|
+
if (
|
|
226
|
+
typeof value !== "string" ||
|
|
227
|
+
!/^editable-artifacts\/[a-z0-9/-]+\/sha256\/$/u.test(value) ||
|
|
228
|
+
value.includes("//") ||
|
|
229
|
+
value.includes("..")
|
|
230
|
+
) {
|
|
231
|
+
throw new Error("Immutable object key prefix is invalid");
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function escapeRegExp(value: string): string {
|
|
237
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateContentType(value: string): void {
|
|
241
|
+
if (
|
|
242
|
+
typeof value !== "string" ||
|
|
243
|
+
value.length < 1 ||
|
|
244
|
+
value.length > 256 ||
|
|
245
|
+
/[\u0000-\u001f\u007f]/u.test(value)
|
|
246
|
+
) {
|
|
247
|
+
throw new Error("Immutable object content type is invalid");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function* fileChunks(
|
|
252
|
+
path: string,
|
|
253
|
+
expectedByteSize: number,
|
|
254
|
+
signal: AbortSignal | undefined,
|
|
255
|
+
): AsyncIterableIterator<Uint8Array> {
|
|
256
|
+
const handle = await open(path, "r");
|
|
257
|
+
try {
|
|
258
|
+
const buffer = new Uint8Array(1024 * 1024);
|
|
259
|
+
let offset = 0;
|
|
260
|
+
while (offset < expectedByteSize) {
|
|
261
|
+
throwIfAborted(signal);
|
|
262
|
+
const length = Math.min(buffer.byteLength, expectedByteSize - offset);
|
|
263
|
+
let filled = 0;
|
|
264
|
+
while (filled < length) {
|
|
265
|
+
const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
|
|
266
|
+
if (bytesRead <= 0) throw new Error("Immutable staging file was truncated");
|
|
267
|
+
filled += bytesRead;
|
|
268
|
+
}
|
|
269
|
+
offset += filled;
|
|
270
|
+
yield buffer.slice(0, filled);
|
|
271
|
+
}
|
|
272
|
+
const extra = new Uint8Array(1);
|
|
273
|
+
if ((await handle.read(extra, 0, 1, expectedByteSize)).bytesRead !== 0) {
|
|
274
|
+
throw new Error("Immutable staging file exceeded its committed size");
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
await handle.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function writeAll(handle: FileHandle, bytes: Uint8Array, position: number): Promise<void> {
|
|
282
|
+
let offset = 0;
|
|
283
|
+
while (offset < bytes.byteLength) {
|
|
284
|
+
const { bytesWritten } = await handle.write(
|
|
285
|
+
bytes,
|
|
286
|
+
offset,
|
|
287
|
+
bytes.byteLength - offset,
|
|
288
|
+
position + offset,
|
|
289
|
+
);
|
|
290
|
+
if (bytesWritten <= 0) throw new Error("Immutable staging write was truncated");
|
|
291
|
+
offset += bytesWritten;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
296
|
+
if (signal?.aborted) throw new Error("Object storage operation was cancelled");
|
|
297
|
+
}
|