@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/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
+ }