1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import type * as HttpClientResponse from "effect/http/HttpClientResponse";
8import { artifactFileStream, type ArtifactFile } from "./ArtifactFile.ts";
9import { PrismaUploadClient } from "./HttpClient.ts";
10
11const ARTIFACT_UPLOAD_TIMEOUT = Duration.minutes(5);
12const UPLOAD_ERROR_BODY_BYTES = 64 * 1024;
13
14export const executeArtifactUpload = (
15 uploadUrl: string,
16 artifact: Uint8Array | ArtifactFile,
17 contentType: string,
18) =>
19 Effect.gen(function* () {
20 // Prefer the Prisma-scoped upload client (node transport, provided by
21 // live wiring — presigned upload URLs need explicit Content-Length on
22 // file-backed bodies); fall back to the ambient client so tests can
23 // stub uploads through `HttpClient.HttpClient`.
24 const uploadClient = yield* Effect.serviceOption(PrismaUploadClient);
25 const http = Option.isSome(uploadClient)
26 ? uploadClient.value
27 : yield* HttpClient.HttpClient;
28 const request = HttpClientRequest.put(uploadUrl).pipe(
29 artifact instanceof Uint8Array
30 ? HttpClientRequest.bodyUint8Array(artifact, contentType)
31 : HttpClientRequest.bodyStream(artifactFileStream(artifact), {
32 contentType,
33 contentLength: artifact.size,
34 }),
35 );
36 const responseOption = yield* http.execute(request).pipe(
37 Effect.mapError(
38 () =>
39 new Error(
40 "Prisma artifact upload transport failed before a response was received.",
41 ),
42 ),
43 Effect.timeoutOption(ARTIFACT_UPLOAD_TIMEOUT),
44 );
45 if (Option.isNone(responseOption)) {
46 return yield* Effect.fail(
47 new Error("Prisma artifact upload timed out after 5 minutes."),
48 );
49 }
50 const response = responseOption.value;
51 if (response.status < 200 || response.status >= 300) {
52 const bodyBytesOption = yield* readResponsePrefixByteCount(
53 response,
54 UPLOAD_ERROR_BODY_BYTES,
55 ).pipe(
56 Effect.timeoutOption(Duration.seconds(2)),
57 Effect.catch(() => Effect.succeed(Option.none<number>())),
58 );
59 const diagnostic = Option.match(bodyBytesOption, {
60 onNone: () => "diagnostic body unavailable",
61 onSome: (bytes) => `diagnostic body prefix: ${bytes} bytes`,
62 });
63 return yield* Effect.fail(
64 new Error(
65 `Prisma artifact upload failed (HTTP ${response.status}; ${diagnostic}).`,
66 ),
67 );
68 }
69 });
70
71const readResponsePrefixByteCount = (
72 response: HttpClientResponse.HttpClientResponse,
73 limit: number,
74) =>
75 Effect.gen(function* () {
76 let bytes = 0;
77 yield* Stream.runForEachWhile(response.stream, (chunk) =>
78 Effect.sync(() => {
79 const remaining = limit - bytes;
80 if (remaining <= 0) return false;
81 bytes += Math.min(chunk.byteLength, remaining);
82 return bytes < limit;
83 }),
84 );
85 return bytes;
86 });