import type { ManagementApiClient, paths } from "@prisma/management-api-sdk"; import { Result } from "better-result"; import { ApiError, AuthenticationError, CancelledError } from "./errors.ts"; export type ApiClientError = CancelledError | AuthenticationError | ApiError; /** * Lean internal wrapper around ManagementApiClient. * * Provides typed, domain-specific methods with structured error handling. * Has no knowledge of OAuth, service tokens, or token storage — it simply * uses the ManagementApiClient it was given. */ export class InternalApiClient { readonly #client: ManagementApiClient; constructor(client: ManagementApiClient) { this.#client = client; } async listProjects(signal?: AbortSignal) { return this.#requestPaginated( (cursor) => this.#client.GET("/v1/projects", { params: { query: cursor ? { cursor } : {} }, signal, }), "/v1/projects", signal, ); } async createProject(body: CreateProjectBody, signal?: AbortSignal) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/projects", { body: body as SdkCreateProjectBody, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/projects", signal, ).then((r) => r.map((body) => body.data)); } async getProject(id: string, signal?: AbortSignal) { return this.#request( this.#client.GET("/v1/projects/{id}", { params: { path: { id } }, signal, }), "/v1/projects/{id}", signal, ).then((r) => r.map((body) => body.data)); } async listProjectServices(projectId: string, signal?: AbortSignal) { return this.#requestPaginated( (cursor) => this.#client.GET("/v1/projects/{projectId}/compute-services", { params: { path: { projectId }, query: cursor ? { cursor } : {} }, signal, }), "/v1/projects/{projectId}/compute-services", signal, ); } async createProjectService( projectId: string, body: CreateProjectServiceBody, signal?: AbortSignal, ) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/projects/{projectId}/compute-services", { params: { path: { projectId } }, body: body as SdkCreateProjectServiceBody, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/projects/{projectId}/compute-services", signal, ).then((r) => r.map((body) => body.data)); } async getService(computeServiceId: string, signal?: AbortSignal) { return this.#request( this.#client.GET("/v1/compute-services/{computeServiceId}", { params: { path: { computeServiceId } }, signal, }), "/v1/compute-services/{computeServiceId}", signal, ).then((r) => r.map((body) => body.data)); } async getBranch(branchId: string, signal?: AbortSignal) { return this.#request( this.#client.GET("/v1/branches/{branchId}", { params: { path: { branchId } }, signal, }), "/v1/branches/{branchId}", signal, ).then((r) => r.map((body) => body.data)); } async deleteService( computeServiceId: string, signal?: AbortSignal, ): Promise> { signal?.throwIfAborted(); return this.#unwrapVoid( this.#client.DELETE("/v1/compute-services/{computeServiceId}", { params: { path: { computeServiceId } }, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), signal, ); } async listServiceVersions(computeServiceId: string, signal?: AbortSignal) { return this.#requestPaginated( (cursor) => this.#client.GET("/v1/compute-services/{computeServiceId}/versions", { params: { path: { computeServiceId }, query: cursor ? { cursor } : {}, }, signal, }), "/v1/compute-services/{computeServiceId}/versions", signal, ); } async createServiceVersion( computeServiceId: string, body?: CreateServiceVersionBody, signal?: AbortSignal, ) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/compute-services/{computeServiceId}/versions", { params: { path: { computeServiceId } }, body, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/compute-services/{computeServiceId}/versions", signal, ).then((r) => r.map((body) => body.data)); } async getVersion(versionId: string, signal?: AbortSignal) { return this.#request( this.#client.GET("/v1/compute-services/versions/{versionId}", { params: { path: { versionId } }, signal, }), "/v1/compute-services/versions/{versionId}", signal, ).then((r) => r.map((body) => body.data)); } async startVersion(versionId: string, signal?: AbortSignal) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/compute-services/versions/{versionId}/start", { params: { path: { versionId } }, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/compute-services/versions/{versionId}/start", signal, ).then((r) => r.map((body) => body.data)); } async stopVersion( versionId: string, signal?: AbortSignal, ): Promise> { signal?.throwIfAborted(); return this.#unwrapVoid( this.#client.POST("/v1/compute-services/versions/{versionId}/stop", { params: { path: { versionId } }, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), signal, ); } async deleteVersion( versionId: string, signal?: AbortSignal, ): Promise> { signal?.throwIfAborted(); return this.#unwrapVoid( this.#client.DELETE("/v1/compute-services/versions/{versionId}", { params: { path: { versionId } }, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), signal, ); } async promoteService( computeServiceId: string, body: PromoteServiceBody, signal?: AbortSignal, ) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/compute-services/{computeServiceId}/promote", { params: { path: { computeServiceId } }, body, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/compute-services/{computeServiceId}/promote", signal, ).then((r) => r.map((body) => body.data)); } async listEnvironmentVariables( query: ListEnvironmentVariablesQuery, signal?: AbortSignal, ) { return this.#requestPaginated( (cursor) => this.#client.GET("/v1/environment-variables", { params: { query: cursor ? { ...query, cursor } : query, }, signal, }), "/v1/environment-variables", signal, ); } async createEnvironmentVariable( body: CreateEnvironmentVariableBody, signal?: AbortSignal, ) { signal?.throwIfAborted(); return this.#request( this.#client.POST("/v1/environment-variables", { body, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/environment-variables", signal, ).then((r) => r.map((body) => body.data)); } async updateEnvironmentVariable( envVarId: string, body: UpdateEnvironmentVariableBody, signal?: AbortSignal, ) { signal?.throwIfAborted(); return this.#request( this.#client.PATCH("/v1/environment-variables/{envVarId}", { params: { path: { envVarId } }, body, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), "/v1/environment-variables/{envVarId}", signal, ).then((r) => r.map((body) => body.data)); } async deleteEnvironmentVariable( envVarId: string, signal?: AbortSignal, ): Promise> { signal?.throwIfAborted(); return this.#unwrapVoid( this.#client.DELETE("/v1/environment-variables/{envVarId}", { params: { path: { envVarId } }, // Intentionally do not pass AbortSignal for mutating operations. // Aborting transport can leave remote completion ambiguous. }), signal, ); } async #request( request: Promise>, endpoint: string, signal?: AbortSignal, ): Promise, ApiClientError>> { let result: RequestResult; try { result = await request; } catch (error) { return Result.err(parseRequestError(error, undefined, signal)); } if (result.error) { return Result.err( parseRequestError(result.error, result.response, signal), ); } const body = result.data; if (body == null) { return Result.err( parseRequestError( undefined, undefined, signal, `Management API returned an empty response body for ${endpoint}`, ), ); } return Result.ok(body); } async #requestPaginated( makeRequest: (cursor: string | undefined) => Promise< RequestResult<{ data: TItem[]; pagination: { nextCursor: string | null; hasMore: boolean }; }> >, endpoint: string, signal?: AbortSignal, ): Promise> { const allItems: TItem[] = []; let cursor: string | undefined; for (;;) { const result = await this.#request(makeRequest(cursor), endpoint, signal); if (result.isErr()) return result; allItems.push(...result.value.data); const nextCursor = result.value.pagination?.nextCursor; if (!nextCursor) break; cursor = nextCursor; } return Result.ok(allItems); } async #unwrapVoid( request: Promise>, signal?: AbortSignal, ): Promise> { let result: RequestResult; try { result = await request; } catch (error) { return Result.err(parseRequestError(error, undefined, signal)); } if (result.error) { return Result.err( parseRequestError(result.error, result.response, signal), ); } return Result.ok(undefined); } } type RequestResult = | { data: TData; error?: never; response: Response } | { data?: never; error: unknown; response: Response }; type SdkCreateProjectBody = NonNullable< paths["/v1/projects"]["post"]["requestBody"] >["content"]["application/json"]; type CreateProjectBody = Omit & { region?: string; }; type SdkCreateProjectServiceBody = NonNullable< paths["/v1/projects/{projectId}/compute-services"]["post"]["requestBody"] >["content"]["application/json"]; type CreateProjectServiceBody = Omit< SdkCreateProjectServiceBody, "regionId" > & { regionId?: string; }; export type CreateServiceVersionBody = NonNullable< paths["/v1/compute-services/{computeServiceId}/versions"]["post"]["requestBody"] >["content"]["application/json"]; export type PromoteServiceBody = NonNullable< paths["/v1/compute-services/{computeServiceId}/promote"]["post"]["requestBody"] >["content"]["application/json"]; type ListEnvironmentVariablesQuery = NonNullable< paths["/v1/environment-variables"]["get"]["parameters"]["query"] >; type CreateEnvironmentVariableBody = NonNullable< paths["/v1/environment-variables"]["post"]["requestBody"] >["content"]["application/json"]; type UpdateEnvironmentVariableBody = NonNullable< paths["/v1/environment-variables/{envVarId}"]["patch"]["requestBody"] >["content"]["application/json"]; const TRACE_HEADER_NAMES = [ "x-request-id", "x-correlation-id", "x-amzn-requestid", "x-amz-request-id", "traceparent", "cf-ray", "cf-placement", ] as const; function extractTraceHeaders(response?: Response): Record { const headers: Record = {}; if (!response) return headers; for (const name of TRACE_HEADER_NAMES) { const value = response.headers.get(name); if (value) { headers[name] = value; } } return headers; } function parseApiError( error: unknown, response?: Response, fallbackMessage?: string, ): AuthenticationError | ApiError { const traceHeaders = extractTraceHeaders(response); if (response?.status === 401) { return new AuthenticationError({ statusCode: 401, message: "Authentication failed (HTTP 401)", }); } const err = error as | { error?: { message?: string; code?: string; hint?: string }; message?: string; code?: string; hint?: string; } | undefined; const message = err?.error?.message ?? err?.message ?? fallbackMessage ?? `Request failed with HTTP ${response?.status ?? "unknown"}`; const code = err?.error?.code ?? err?.code; const hint = err?.error?.hint ?? err?.hint; return new ApiError({ statusCode: response?.status ?? 0, statusText: response?.statusText ?? "", code, message, hint, traceHeaders, }); } function parseRequestError( error: unknown, response?: Response, signal?: AbortSignal, fallbackMessage?: string, ): ApiClientError { if (signal?.aborted) { return new CancelledError(); } return parseApiError(error, response, fallbackMessage); } /** Extract the upload URL from a version creation response. */ export function readUploadUrl(data: { uploadUrl?: string | null; }): string | null { if (typeof data.uploadUrl === "string" && data.uploadUrl.length > 0) { return data.uploadUrl; } return null; } /** Normalize a preview domain to a clickable https URL. */ export function toDeploymentUrl(value: string): string { const normalized = value.trim(); if (normalized.length === 0) return normalized; if (normalized.startsWith("//")) return `https:${normalized}`; if (/^https?:\/\//i.test(normalized)) return normalized; return `https://${normalized}`; }