@opengeni/storage 0.2.126 → 0.2.127-canary.0

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
@@ -44,6 +44,12 @@ export type ObjectHead = {
44
44
  VersionToken?: string;
45
45
  };
46
46
 
47
+ export {
48
+ uploadWorkspaceArchiveSpool,
49
+ downloadWorkspaceArchiveSpool,
50
+ WorkspaceArchiveStorageError,
51
+ } from "./workspace-archive-spool";
52
+
47
53
  export type ObjectStorage = {
48
54
  bucket: string;
49
55
  backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
@@ -102,6 +108,19 @@ export type ObjectStorage = {
102
108
  body: Uint8Array;
103
109
  sha256?: string | null;
104
110
  }) => Promise<void>;
111
+ /**
112
+ * Unconditional authenticated upload from a bounded byte stream. May overwrite
113
+ * an existing key; this is NOT an atomic create-only operation. Callers needing
114
+ * write isolation must supply a fresh unique key and verify stored content.
115
+ */
116
+ putObjectStream?: (args: {
117
+ key: string;
118
+ contentType: string;
119
+ chunks: AsyncIterable<Uint8Array>;
120
+ byteSize: number;
121
+ sha256?: string;
122
+ signal?: AbortSignal;
123
+ }) => Promise<void>;
105
124
  /** Atomic create-only raw PUT. Returns false when the key already exists. */
106
125
  putObjectIfAbsent?: (args: {
107
126
  key: string;
@@ -254,6 +273,52 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
254
273
  throw error;
255
274
  }
256
275
  },
276
+ async putObjectStream(args) {
277
+ const body = Readable.from(args.chunks, {
278
+ objectMode: false,
279
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
280
+ });
281
+ // A producer error can leave the HTTP request waiting for its advertised
282
+ // ContentLength. Abort this request explicitly; never abort the caller's
283
+ // controller or rely on provider timeouts to settle the failed upload.
284
+ const request = new AbortController();
285
+ let producerFailed = false;
286
+ let producerError: unknown;
287
+ const onBodyError = (error: unknown) => {
288
+ producerFailed = true;
289
+ producerError = error;
290
+ request.abort(error);
291
+ };
292
+ const onCallerAbort = () => request.abort(args.signal?.reason);
293
+ body.once("error", onBodyError);
294
+ // Keep an error listener through asynchronous destruction, then release it.
295
+ const detachBodyError = () => body.off("error", onBodyError);
296
+ body.once("close", detachBodyError);
297
+ args.signal?.addEventListener("abort", onCallerAbort, { once: true });
298
+ if (args.signal?.aborted) onCallerAbort();
299
+ try {
300
+ await requestClient.send(
301
+ new PutObjectCommand({
302
+ Bucket: settings.objectStorageBucket,
303
+ Key: args.key,
304
+ ContentType: args.contentType,
305
+ ContentLength: args.byteSize,
306
+ Body: body,
307
+ Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,
308
+ }),
309
+ { abortSignal: request.signal },
310
+ );
311
+ if (producerFailed) throw producerError;
312
+ } catch (error) {
313
+ // The SDK commonly rejects with AbortError after our producer abort;
314
+ // preserve the original integrity/source failure for its caller.
315
+ if (producerFailed) throw producerError;
316
+ throw error;
317
+ } finally {
318
+ args.signal?.removeEventListener("abort", onCallerAbort);
319
+ body.destroy();
320
+ }
321
+ },
257
322
  async putObjectStreamIfAbsent(args) {
258
323
  try {
259
324
  await requestClient.send(
@@ -516,6 +581,23 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
516
581
  throw error;
517
582
  }
518
583
  },
584
+ async putObjectStream(args) {
585
+ const destination = bucket.file(args.key).createWriteStream({
586
+ resumable: false,
587
+ contentType: args.contentType,
588
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
589
+ ...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),
590
+ });
591
+ const source = Readable.from(args.chunks, {
592
+ objectMode: false,
593
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
594
+ });
595
+ if (args.signal) {
596
+ await pipeline(source, destination, { signal: args.signal });
597
+ } else {
598
+ await pipeline(source, destination);
599
+ }
600
+ },
519
601
  async putObjectStreamIfAbsent(args) {
520
602
  const destination = bucket.file(args.key).createWriteStream({
521
603
  resumable: false,
@@ -709,6 +791,27 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
709
791
  throw error;
710
792
  }
711
793
  },
794
+ async putObjectStream(args) {
795
+ const blobClient = requestContainerClient.getBlockBlobClient(args.key);
796
+ const source = Readable.from(args.chunks, {
797
+ objectMode: false,
798
+ highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
799
+ });
800
+ try {
801
+ await blobClient.uploadStream(
802
+ source,
803
+ INTERNAL_STREAM_BUFFER_BYTES,
804
+ INTERNAL_STREAM_CONCURRENCY,
805
+ {
806
+ blobHTTPHeaders: { blobContentType: args.contentType },
807
+ ...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
808
+ ...(args.signal ? { abortSignal: args.signal } : {}),
809
+ },
810
+ );
811
+ } finally {
812
+ source.destroy();
813
+ }
814
+ },
712
815
  async putObjectStreamIfAbsent(args) {
713
816
  const blobClient = requestContainerClient.getBlockBlobClient(args.key);
714
817
  try {
@@ -0,0 +1,308 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { chmod, mkdtemp, open, rm, type FileHandle } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ObjectStorage } from "./index";
7
+ import { DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES } from "./bounded-object-read";
8
+
9
+ /** Structural runtime-compatible contract; storage must not depend on runtime. */
10
+ export type WorkspaceArchiveSpool = {
11
+ path: string;
12
+ byteSize: number;
13
+ sha256: string;
14
+ open: () => AsyncIterable<Uint8Array>;
15
+ dispose: () => Promise<void>;
16
+ };
17
+
18
+ // Transfer granularity, not an archive size limit.
19
+ const CHUNK_BYTES = DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
20
+ type ExpectedArchive = { bytes: number; sha256: string };
21
+
22
+ /** Restore classification shared with runtime without importing runtime. */
23
+ export class WorkspaceArchiveStorageError extends Error {
24
+ constructor(
25
+ readonly code: "archive_base64_invalid" | "archive_hash_mismatch" | "archive_hydration_failed",
26
+ message: string,
27
+ readonly retryable: boolean,
28
+ options?: ErrorOptions,
29
+ ) {
30
+ super(message, options);
31
+ this.name = "WorkspaceArchiveStorageError";
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Uploads at a caller-owned fresh unique key and independently verifies readback.
37
+ * Unconditional PUT may overwrite: callers must never reuse published keys.
38
+ * Never owns/disposes the input spool or deletes a failed/unpublished object.
39
+ */
40
+ export async function uploadWorkspaceArchiveSpool(
41
+ storage: ObjectStorage,
42
+ key: string,
43
+ spool: WorkspaceArchiveSpool,
44
+ ): Promise<void> {
45
+ requireBoundedReads(storage);
46
+ if (!storage.putObjectStream) {
47
+ throw new WorkspaceArchiveStorageError(
48
+ "archive_hydration_failed",
49
+ "Workspace archive storage unsupported: unconditional streaming upload required",
50
+ false,
51
+ );
52
+ }
53
+ const expected = { bytes: spool.byteSize, sha256: spool.sha256 };
54
+ validateExpected(expected);
55
+ let validated = false;
56
+ let streamFailed = false;
57
+ let streamFailure: unknown;
58
+ const chunks = (async function* () {
59
+ const digest = createHash("sha256");
60
+ let bytes = 0;
61
+ try {
62
+ for await (const chunk of spool.open()) {
63
+ if (!(chunk instanceof Uint8Array) || chunk.byteLength > expected.bytes - bytes) {
64
+ throw new WorkspaceArchiveStorageError(
65
+ "archive_hash_mismatch",
66
+ "Workspace archive upload stream has invalid size",
67
+ false,
68
+ );
69
+ }
70
+ bytes += chunk.byteLength;
71
+ digest.update(chunk);
72
+ yield chunk;
73
+ }
74
+ if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
75
+ throw new WorkspaceArchiveStorageError(
76
+ "archive_hash_mismatch",
77
+ "Workspace archive upload stream digest or size mismatch",
78
+ false,
79
+ );
80
+ }
81
+ validated = true;
82
+ } catch (error) {
83
+ streamFailed = true;
84
+ streamFailure = error;
85
+ throw error;
86
+ }
87
+ })();
88
+ try {
89
+ await storage.putObjectStream({
90
+ key,
91
+ contentType: "application/x-tar",
92
+ chunks,
93
+ byteSize: expected.bytes,
94
+ sha256: expected.sha256,
95
+ });
96
+ if (!validated) {
97
+ if (streamFailed) throw streamFailure;
98
+ throw new WorkspaceArchiveStorageError(
99
+ "archive_hydration_failed",
100
+ "Workspace archive upload provider did not completely consume and validate the stream",
101
+ false,
102
+ );
103
+ }
104
+ // A successful PUT and SHA metadata are not proof of stored content.
105
+ await verifyRanges(storage, key, expected);
106
+ } catch (error) {
107
+ const failure = streamFailed ? streamFailure : error;
108
+ if (failure instanceof WorkspaceArchiveStorageError) throw failure;
109
+ throw new WorkspaceArchiveStorageError(
110
+ "archive_hydration_failed",
111
+ "Workspace archive upload or readback failed",
112
+ true,
113
+ { cause: failure },
114
+ );
115
+ } finally {
116
+ // Close an early-terminated provider's iterator without owning the spool.
117
+ await chunks.return(undefined);
118
+ }
119
+ }
120
+
121
+ /** Caller owns the returned private spool and must dispose it after use. */
122
+ export async function downloadWorkspaceArchiveSpool(
123
+ storage: ObjectStorage,
124
+ key: string,
125
+ inputExpected: ExpectedArchive,
126
+ ): Promise<WorkspaceArchiveSpool> {
127
+ const expected = { bytes: inputExpected.bytes, sha256: inputExpected.sha256 };
128
+ requireBoundedReads(storage);
129
+ validateExpected(expected);
130
+ const directory = await mkdtemp(join(tmpdir(), "opengeni-workspace-archive-"));
131
+ const path = join(directory, "archive.tar");
132
+ let handle: FileHandle | undefined;
133
+ try {
134
+ await chmod(directory, 0o700);
135
+ handle = await open(path, "wx", 0o600);
136
+ await verifyRanges(storage, key, expected, async (chunk) => {
137
+ let offset = 0;
138
+ while (offset < chunk.byteLength) {
139
+ const { bytesWritten } = await handle!.write(chunk, offset, chunk.byteLength - offset);
140
+ if (bytesWritten <= 0)
141
+ throw new WorkspaceArchiveStorageError(
142
+ "archive_hydration_failed",
143
+ "Workspace archive spool write made no progress",
144
+ true,
145
+ );
146
+ offset += bytesWritten;
147
+ }
148
+ });
149
+ await handle.close();
150
+ handle = undefined;
151
+ let disposed = false;
152
+ return {
153
+ path,
154
+ byteSize: expected.bytes,
155
+ sha256: expected.sha256,
156
+ async *open() {
157
+ if (disposed) throw new Error("Workspace archive spool is disposed");
158
+ const stream = createReadStream(path, { highWaterMark: CHUNK_BYTES });
159
+ try {
160
+ for await (const chunk of stream) yield chunk as Uint8Array;
161
+ } finally {
162
+ stream.destroy();
163
+ }
164
+ },
165
+ async dispose() {
166
+ disposed = true;
167
+ await rm(directory, { recursive: true, force: true });
168
+ },
169
+ };
170
+ } catch (error) {
171
+ await handle?.close().catch(() => undefined);
172
+ await rm(directory, { recursive: true, force: true });
173
+ if (error instanceof WorkspaceArchiveStorageError) throw error;
174
+ throw new WorkspaceArchiveStorageError(
175
+ "archive_hydration_failed",
176
+ "Workspace archive download failed",
177
+ true,
178
+ { cause: error },
179
+ );
180
+ }
181
+ }
182
+
183
+ function requireBoundedReads(storage: ObjectStorage): void {
184
+ if (!storage.headObject || !storage.getObjectRange) {
185
+ throw new WorkspaceArchiveStorageError(
186
+ "archive_hydration_failed",
187
+ "Workspace archive storage unsupported: versioned head/range reads required",
188
+ false,
189
+ );
190
+ }
191
+ }
192
+
193
+ function validateExpected(expected: ExpectedArchive): void {
194
+ if (
195
+ !Number.isSafeInteger(expected.bytes) ||
196
+ expected.bytes < 0 ||
197
+ !/^[0-9a-f]{64}$/u.test(expected.sha256)
198
+ ) {
199
+ throw new WorkspaceArchiveStorageError(
200
+ "archive_hydration_failed",
201
+ "Workspace archive expected size or SHA-256 is invalid",
202
+ false,
203
+ );
204
+ }
205
+ }
206
+
207
+ async function verifyRanges(
208
+ storage: ObjectStorage,
209
+ key: string,
210
+ expected: ExpectedArchive,
211
+ consume?: (bytes: Uint8Array) => Promise<void>,
212
+ ): Promise<void> {
213
+ const head = await storage.headObject!(key);
214
+ if (!head)
215
+ throw new WorkspaceArchiveStorageError(
216
+ "archive_base64_invalid",
217
+ "Workspace archive object is missing",
218
+ false,
219
+ );
220
+ if (head.ContentLength !== expected.bytes)
221
+ throw new WorkspaceArchiveStorageError(
222
+ "archive_hash_mismatch",
223
+ "Workspace archive object size mismatch",
224
+ false,
225
+ );
226
+ const version = head.VersionToken;
227
+ if (typeof version !== "string" || version.length === 0 || version.length > 2048) {
228
+ throw new WorkspaceArchiveStorageError(
229
+ "archive_hydration_failed",
230
+ "Workspace archive storage unsupported: valid object version token required",
231
+ false,
232
+ );
233
+ }
234
+ const digest = createHash("sha256");
235
+ let bytes = 0;
236
+ while (bytes < expected.bytes) {
237
+ const length = Math.min(CHUNK_BYTES, expected.bytes - bytes);
238
+ const result = await storage.getObjectRange!({
239
+ key,
240
+ start: bytes,
241
+ endInclusive: bytes + length - 1,
242
+ expectedVersionToken: version,
243
+ });
244
+ if (!result) {
245
+ // Adapters also return null for failed If-Match/generation reads. Do not
246
+ // classify replacement as permanent loss or retry without a version pin.
247
+ const current = await storage.headObject!(key);
248
+ if (!current) {
249
+ throw new WorkspaceArchiveStorageError(
250
+ "archive_base64_invalid",
251
+ "Workspace archive object is missing during range read",
252
+ false,
253
+ );
254
+ }
255
+ throw new WorkspaceArchiveStorageError(
256
+ "archive_hydration_failed",
257
+ current.VersionToken !== version
258
+ ? "Workspace archive object version changed"
259
+ : "Workspace archive pinned range is temporarily unavailable",
260
+ true,
261
+ );
262
+ }
263
+ if (result.versionToken !== version)
264
+ throw new WorkspaceArchiveStorageError(
265
+ "archive_hydration_failed",
266
+ "Workspace archive object version changed",
267
+ true,
268
+ );
269
+ if (!(result.bytes instanceof Uint8Array) || result.bytes.byteLength !== length) {
270
+ throw new WorkspaceArchiveStorageError(
271
+ "archive_hash_mismatch",
272
+ "Workspace archive object range is truncated or has invalid size",
273
+ false,
274
+ );
275
+ }
276
+ digest.update(result.bytes);
277
+ await consume?.(result.bytes);
278
+ bytes += result.bytes.byteLength;
279
+ }
280
+ const finalHead = await storage.headObject!(key);
281
+ if (!finalHead)
282
+ throw new WorkspaceArchiveStorageError(
283
+ "archive_base64_invalid",
284
+ "Workspace archive object is missing after range read",
285
+ false,
286
+ );
287
+ if (finalHead.VersionToken !== version) {
288
+ throw new WorkspaceArchiveStorageError(
289
+ "archive_hydration_failed",
290
+ "Workspace archive object version changed",
291
+ true,
292
+ );
293
+ }
294
+ if (finalHead.ContentLength !== expected.bytes) {
295
+ throw new WorkspaceArchiveStorageError(
296
+ "archive_hash_mismatch",
297
+ "Workspace archive object size changed",
298
+ false,
299
+ );
300
+ }
301
+ if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
302
+ throw new WorkspaceArchiveStorageError(
303
+ "archive_hash_mismatch",
304
+ "Workspace archive object digest or size mismatch",
305
+ false,
306
+ );
307
+ }
308
+ }