import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { type InferOk, Result } from "better-result"; import invariant from "tiny-invariant"; import { InternalApiClient, readUploadUrl, toDeploymentUrl, } from "./api-client.ts"; import { createArchive } from "./archive.ts"; import type { BuildStrategy } from "./build-strategy.ts"; import type { DeployInteraction, DeployProgress, DestroyServiceProgress, DestroyVersionInteraction, DestroyVersionProgress, PromoteProgress, UpdateEnvProgress, } from "./callbacks.ts"; import { ApiError, type ApiRequestError, ArtifactError, type AuthenticationError, BuildError, CancelledError, type DeployError, DestroyAggregateError, type DestroyServiceError, type DestroyVersionError, InvalidOptionsError, MissingArgumentError, NoExistingVersionError, NoVersionsFoundError, type PromoteError, type UpdateEnvError, type VersionOperationError, } from "./errors.ts"; import { pollVersionStatus } from "./polling.ts"; import type { CreateProjectResult, PortMapping, ProjectInfo, ResolvedConfig, ServiceDetail, ServiceInfo, VersionDetail, VersionInfo, } from "./types.ts"; import { REGIONS } from "./types.ts"; import { uploadArtifact } from "./upload-artifact.ts"; type VersionDetailData = InferOk< Awaited> >; export interface DeployOptions { strategy: BuildStrategy; projectId?: string; serviceId?: string; serviceName?: string; region?: string; envVars?: Record; portMapping?: PortMapping; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DeployInteraction; progress?: DeployProgress; skipPromote?: boolean; destroyOldVersion?: boolean; } export interface UpdateEnvOptions { projectId?: string; serviceId?: string; envVars?: Record; portMapping?: PortMapping; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DeployInteraction; progress?: UpdateEnvProgress; } export interface DestroyVersionOptions { versionId?: string; serviceId?: string; projectId?: string; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DestroyVersionInteraction; progress?: DestroyVersionProgress; } export interface DestroyServiceOptions { serviceId: string; keepService?: boolean; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; progress?: DestroyServiceProgress; } export interface ListServicesOptions { projectId: string; signal?: AbortSignal; } export interface CreateServiceOptions { projectId: string; serviceName: string; region: string; signal?: AbortSignal; } export interface ShowServiceOptions { serviceId: string; signal?: AbortSignal; } export interface DeleteServiceOptions { serviceId: string; signal?: AbortSignal; } export interface CreateProjectOptions { name: string; createDatabase?: boolean; region?: string; signal?: AbortSignal; } export interface ListProjectsOptions { signal?: AbortSignal; } export interface ListVersionsOptions { serviceId: string; signal?: AbortSignal; } export interface ShowVersionOptions { versionId: string; signal?: AbortSignal; } export interface StartVersionOptions { versionId: string; signal?: AbortSignal; } export interface StopVersionOptions { versionId: string; signal?: AbortSignal; } export interface DeleteVersionOptions { versionId: string; signal?: AbortSignal; } export interface PromoteOptions { serviceId: string; versionId?: string; timeoutSeconds?: number; pollIntervalMs?: number; signal?: AbortSignal; interaction?: DestroyVersionInteraction; progress?: PromoteProgress; } export interface DeployResult { projectId: string; serviceId: string; serviceName: string; region: string; versionId: string; versionEndpointDomain: string; serviceEndpointDomain: string | null; promoted: boolean; previousVersionId: string | null; previousVersionAction: "stopped" | "destroyed" | "still-active" | null; resolvedConfig: ResolvedConfig; } export interface PromoteResult { serviceId: string; versionId: string; serviceEndpointDomain: string; versionStarted: boolean; } export interface UpdateEnvResult { projectId: string; serviceId: string; versionId: string; deploymentUrl: string; resolvedConfig: ResolvedConfig; } export interface DestroyVersionResult { versionId: string; previousStatus: string; stopped: boolean; deleted: boolean; } export interface DestroyServiceResult { serviceId: string; deletedVersionIds: string[]; serviceDeleted: boolean; } const DEFAULT_TIMEOUT_SECONDS = 120; const DEFAULT_POLL_INTERVAL_MS = 1_000; export class ComputeClient { readonly #api: InternalApiClient; constructor(managementApiClient: ManagementApiClient) { this.#api = new InternalApiClient(managementApiClient); } async deploy( options: DeployOptions, ): Promise> { if (options.skipPromote && options.destroyOldVersion) { return Result.err( new InvalidOptionsError({ message: "destroyOldVersion cannot be combined with skipPromote — the old version stays active when promotion is skipped", }), ); } const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const target = yield* Result.await(self.#resolveDeployTarget(options)); options.progress?.onBuildStart?.(); const artifact = yield* Result.await( Result.tryPromise({ try: () => options.strategy.execute(options.signal), catch: (e) => new BuildError({ message: e instanceof Error ? e.message : String(e), }), }), ); options.progress?.onBuildComplete?.(artifact); try { yield* self.#checkAborted(options.signal); options.progress?.onArchiveCreating?.(); const archiveBytes = yield* Result.await( Result.tryPromise({ try: () => createArchive( artifact.directory, artifact.entrypoint, options.signal, ), catch: (e) => new ArtifactError({ message: e instanceof Error ? e.message : `Archive creation failed: ${String(e)}`, cause: e, }), }), ); options.progress?.onArchiveReady?.(archiveBytes.byteLength); yield* self.#checkAborted(options.signal); const portMapping = options.portMapping ?? (target.isNewService && artifact.defaultPortMapping ? artifact.defaultPortMapping : undefined); yield* Result.await( self.#applyEnvVars( target.projectId, target.branchId, options.envVars, options.signal, ), ); const createResponse = yield* Result.await( self.#api.createServiceVersion( target.serviceId, portMapping ? { portMapping } : {}, options.signal, ), ); options.progress?.onVersionCreated?.(createResponse.id); const uploadUrl = readUploadUrl(createResponse); if (!uploadUrl) { return Result.err( new ArtifactError({ message: "Version creation did not return an upload URL", }), ); } yield* self.#checkAborted(options.signal); options.progress?.onUploadStart?.(); yield* Result.await( self.#uploadArtifactResult(uploadUrl, archiveBytes, options.signal), ); options.progress?.onUploadComplete?.(); yield* self.#checkAborted(options.signal); const service = yield* Result.await( self.#api.getService(target.serviceId, options.signal), ); const previousVersionId = service.latestVersionId ?? null; options.progress?.onStartRequested?.(); yield* Result.await( self.#api.startVersion(createResponse.id, options.signal), ); const pollResult = yield* Result.await( pollVersionStatus(self.#api, createResponse.id, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }), ); const versionEndpointDomain = toDeploymentUrl(pollResult.previewDomain); options.progress?.onRunning?.(versionEndpointDomain); const resolvedConfig: ResolvedConfig = { projectId: target.projectId, serviceId: target.serviceId, serviceName: target.serviceName, region: target.region, portMapping: options.portMapping, }; if (options.skipPromote) { return Result.ok({ projectId: target.projectId, serviceId: target.serviceId, serviceName: target.serviceName, region: target.region, versionId: createResponse.id, versionEndpointDomain, serviceEndpointDomain: null, promoted: false, previousVersionId, previousVersionAction: previousVersionId ? ("still-active" as const) : null, resolvedConfig, }); } const switchoverResult = yield* Result.await( self.#promoteAndSwitchover( target.serviceId, createResponse.id, previousVersionId, options.destroyOldVersion ?? false, options.signal, options.progress, ), ); return Result.ok({ projectId: target.projectId, serviceId: target.serviceId, serviceName: target.serviceName, region: target.region, versionId: createResponse.id, versionEndpointDomain, serviceEndpointDomain: switchoverResult.serviceEndpointDomain, promoted: true, previousVersionId: switchoverResult.previousVersionId, previousVersionAction: switchoverResult.previousVersionAction, resolvedConfig, }); } finally { await artifact.cleanup?.(); } }); } async updateEnv( options: UpdateEnvOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const target = yield* Result.await(self.#resolveDeployTarget(options)); const versions = yield* Result.await( self.#api.listServiceVersions(target.serviceId, options.signal), ); if (versions.length === 0) { return Result.err( new NoExistingVersionError({ serviceId: target.serviceId }), ); } yield* self.#checkAborted(options.signal); yield* Result.await( self.#applyEnvVars( target.projectId, target.branchId, options.envVars, options.signal, ), ); yield* self.#checkAborted(options.signal); const createResponse = yield* Result.await( self.#api.createServiceVersion( target.serviceId, { ...(options.portMapping !== undefined ? { portMapping: options.portMapping } : {}), skipCodeUpload: true, }, options.signal, ), ); options.progress?.onVersionCreated?.(createResponse.id); yield* self.#checkAborted(options.signal); options.progress?.onStartRequested?.(); yield* Result.await( self.#api.startVersion(createResponse.id, options.signal), ); const pollResult = yield* Result.await( pollVersionStatus(self.#api, createResponse.id, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }), ); const deploymentUrl = toDeploymentUrl(pollResult.previewDomain); options.progress?.onRunning?.(deploymentUrl); return Result.ok({ projectId: target.projectId, serviceId: target.serviceId, versionId: createResponse.id, deploymentUrl, resolvedConfig: { projectId: target.projectId, serviceId: target.serviceId, serviceName: target.serviceName, region: target.region, portMapping: options.portMapping, }, }); }); } async destroyVersion( options: DestroyVersionOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); let versionId = options.versionId; let versionDetailsCache: Map | undefined; if (!versionId) { const serviceId = options.serviceId; if (!serviceId) { return Result.err(new MissingArgumentError({ field: "versionId" })); } const versions = yield* Result.await( self.#api.listServiceVersions(serviceId, options.signal), ); const versionDetailResults = await Promise.all( versions.map((v) => self.#api.getVersion(v.id, options.signal)), ); const details: VersionDetailData[] = []; for (const result of versionDetailResults) { if (result.isErr()) return result; details.push(result.value); } versionDetailsCache = new Map(details.map((v) => [v.id, v])); const versionInfos = details .map( (v): VersionInfo => ({ id: v.id, status: v.status, createdAt: v.createdAt, previewDomain: v.previewDomain, }), ) .sort(versionSortComparator); if (versionInfos.length === 0) { return Result.err(new NoVersionsFoundError({ serviceId })); } if (!options.interaction?.selectVersion) { return Result.err(new MissingArgumentError({ field: "versionId" })); } versionId = await options.interaction.selectVersion(versionInfos); } const version = versionDetailsCache?.get(versionId) ?? (yield* Result.await(self.#api.getVersion(versionId, options.signal))); const previousStatus = version.status ?? "unknown"; const shouldStop = version.status === "running" || version.status === "provisioning"; let stopped = false; if (shouldStop) { options.progress?.onStopRequested?.(versionId); yield* Result.await(self.#api.stopVersion(versionId, options.signal)); yield* Result.await( pollVersionStatus(self.#api, versionId, { targetStatus: "stopped", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, }), ); stopped = true; options.progress?.onStopped?.(versionId); } yield* Result.await(self.#api.deleteVersion(versionId, options.signal)); options.progress?.onDeleted?.(versionId); return Result.ok({ versionId, previousStatus, stopped, deleted: true, }); }); } async destroyService( options: DestroyServiceOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const versions = yield* Result.await( self.#api.listServiceVersions(options.serviceId, options.signal), ); const versionDetailResults = await Promise.all( versions.map((v) => self.#api.getVersion(v.id, options.signal)), ); const versionDetails: VersionDetailData[] = []; for (const result of versionDetailResults) { if (result.isErr()) return result; versionDetails.push(result.value); } const activeVersions = versionDetails.filter( (v) => v.status === "running" || v.status === "provisioning", ); const succeededIds: string[] = []; const failures: Array<{ versionId: string; error: VersionOperationError; }> = []; if (activeVersions.length > 0) { const activeIds = activeVersions.map((v) => v.id); options.progress?.onStoppingVersions?.(activeIds); const stopResults = await Promise.all( activeVersions.map((v) => self.#api.stopVersion(v.id, options.signal), ), ); const stoppedCandidates: typeof activeVersions = []; for (const [i, result] of stopResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const activeVersion = activeVersions[i]!; if (result.isErr()) { failures.push({ versionId: activeVersion.id, error: result.error, }); } else { stoppedCandidates.push(activeVersion); } } if (stoppedCandidates.length > 0) { const pollResults = await Promise.all( stoppedCandidates.map((v) => pollVersionStatus(self.#api, v.id, { targetStatus: "stopped", timeoutSeconds, pollIntervalMs, signal: options.signal, }), ), ); for (const [i, result] of pollResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const stoppedVersion = stoppedCandidates[i]!; if (result.isErr()) { failures.push({ versionId: stoppedVersion.id, error: result.error, }); } else { options.progress?.onVersionStopped?.(stoppedVersion.id); } } } if (failures.length > 0) { return Result.err( new DestroyAggregateError({ succeededVersionIds: succeededIds, failures, serviceDeleted: false, }), ); } options.progress?.onAllVersionsStopped?.(); } if (versions.length > 0) { const versionIds = versions.map((v) => v.id); options.progress?.onDeletingVersions?.(versionIds); const deleteResults = await Promise.all( versions.map((v) => self.#api.deleteVersion(v.id, options.signal)), ); for (const [i, result] of deleteResults.entries()) { // biome-ignore lint/style/noNonNullAssertion: both lists have the same length const version = versions[i]!; if (result.isErr()) { failures.push({ versionId: version.id, error: result.error, }); } else { succeededIds.push(version.id); options.progress?.onVersionDeleted?.(version.id); } } if (failures.length > 0) { return Result.err( new DestroyAggregateError({ succeededVersionIds: succeededIds, failures, serviceDeleted: false, }), ); } options.progress?.onAllVersionsDeleted?.(); } let serviceDeleted = false; if (!options.keepService) { yield* Result.await( self.#api.deleteService(options.serviceId, options.signal), ); serviceDeleted = true; options.progress?.onServiceDeleted?.(options.serviceId); } return Result.ok({ serviceId: options.serviceId, deletedVersionIds: succeededIds, serviceDeleted, }); }); } async createProject( options: CreateProjectOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const createDatabase = options.createDatabase ?? false; const response = yield* Result.await( self.#api.createProject( { name: options.name, createDatabase, region: options.region, }, options.signal, ), ); const result: CreateProjectResult = { id: response.id, name: response.name, defaultRegion: response.defaultRegion ?? undefined, }; if (response.database) { const connection = response.database.connections?.[0]; result.database = { id: response.database.id, name: response.database.name, region: response.database.region.id, connectionString: connection?.endpoints?.direct?.connectionString ?? undefined, }; } return Result.ok(result); }); } async listProjects( options: ListProjectsOptions = {}, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const projects = yield* Result.await( self.#api.listProjects(options.signal), ); return Result.ok( projects.map( (p): ProjectInfo => ({ id: p.id, name: p.name, defaultRegion: p.defaultRegion ?? undefined, }), ), ); }); } async listServices( options: ListServicesOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const services = yield* Result.await( self.#api.listProjectServices(options.projectId, options.signal), ); return Result.ok( services.map( (s): ServiceInfo => ({ id: s.id, name: s.name, region: s.region.id, projectId: s.projectId, }), ), ); }); } async createService( options: CreateServiceOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const response = yield* Result.await( self.#api.createProjectService( options.projectId, { displayName: options.serviceName, regionId: options.region, }, options.signal, ), ); return Result.ok({ id: response.id, name: response.name, region: response.region.id, projectId: options.projectId, }); }); } async showService( options: ShowServiceOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const s = yield* Result.await( self.#api.getService(options.serviceId, options.signal), ); return Result.ok({ id: s.id, name: s.name, region: s.region.id, projectId: s.projectId, latestVersionId: s.latestVersionId, serviceEndpointDomain: s.serviceEndpointDomain, }); }); } async deleteService( options: DeleteServiceOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.deleteService(options.serviceId, options.signal), ); return Result.ok(); }); } async listVersions( options: ListVersionsOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const versions = yield* Result.await( self.#api.listServiceVersions(options.serviceId, options.signal), ); const detailResults = await Promise.all( versions.map((v) => self.#api.getVersion(v.id, options.signal)), ); const details: VersionDetailData[] = []; for (const result of detailResults) { if (result.isErr()) { // listServiceVersions returns versions that have previously been deleted // and can't be fetched using getVersion so we need to filter those out. if (ApiError.is(result.error) && result.error.statusCode === 404) continue; return result; } details.push(result.value); } return Result.ok( details.map( (v): VersionInfo => ({ id: v.id, status: v.status, createdAt: v.createdAt, previewDomain: v.previewDomain, }), ), ); }); } async showVersion( options: ShowVersionOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); const v = yield* Result.await( self.#api.getVersion(options.versionId, options.signal), ); return Result.ok({ id: v.id, status: v.status, createdAt: v.createdAt, previewDomain: v.previewDomain, envVars: v.envVars ?? undefined, }); }); } async startVersion( options: StartVersionOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.startVersion(options.versionId, options.signal), ); return Result.ok(); }); } async stopVersion( options: StopVersionOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.stopVersion(options.versionId, options.signal), ); return Result.ok(); }); } async deleteVersion( options: DeleteVersionOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); yield* Result.await( self.#api.deleteVersion(options.versionId, options.signal), ); return Result.ok(); }); } async promote( options: PromoteOptions, ): Promise> { const self = this; return Result.gen(async function* () { yield* self.#checkAborted(options.signal); let versionId = options.versionId; if (!versionId) { const versions = yield* Result.await( self.#api.listServiceVersions(options.serviceId, options.signal), ); const detailResults = await Promise.all( versions.map((v) => self.#api.getVersion(v.id, options.signal)), ); const details: VersionDetailData[] = []; for (const result of detailResults) { if (result.isErr()) { if (ApiError.is(result.error) && result.error.statusCode === 404) continue; return result; } details.push(result.value); } const versionInfos = details .map( (v): VersionInfo => ({ id: v.id, status: v.status, createdAt: v.createdAt, previewDomain: v.previewDomain, }), ) .sort(versionSortComparator); if (versionInfos.length === 0) { return Result.err( new NoVersionsFoundError({ serviceId: options.serviceId }), ); } if (!options.interaction?.selectVersion) { return Result.err(new MissingArgumentError({ field: "versionId" })); } versionId = await options.interaction.selectVersion(versionInfos); } const version = yield* Result.await( self.#api.getVersion(versionId, options.signal), ); let versionStarted = false; if (version.status !== "running") { options.progress?.onVersionStarting?.(versionId); if (version.status !== "provisioning") { options.progress?.onVersionStartRequested?.(); const startResult = await self.#api.startVersion( versionId, options.signal, ); if (startResult.isErr()) { options.progress?.onPromoteFailed?.(startResult.error); return startResult; } } const pollResult = await pollVersionStatus(self.#api, versionId, { targetStatus: "running", timeoutSeconds: options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS, pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, signal: options.signal, onStatusChange: options.progress?.onStatusChange, }); if (pollResult.isErr()) { options.progress?.onPromoteFailed?.(pollResult.error); return pollResult; } versionStarted = true; options.progress?.onVersionRunning?.(); } options.progress?.onPromoteStart?.(); const promoteResult = await self.#api.promoteService( options.serviceId, { versionId }, options.signal, ); if (promoteResult.isErr()) { options.progress?.onPromoteFailed?.(promoteResult.error); return promoteResult; } options.progress?.onPromoted?.(promoteResult.value.serviceEndpointDomain); return Result.ok({ serviceId: options.serviceId, versionId, serviceEndpointDomain: promoteResult.value.serviceEndpointDomain, versionStarted, }); }); } async #promoteAndSwitchover( serviceId: string, newVersionId: string, previousVersionId: string | null, destroyOldVersion: boolean, signal?: AbortSignal, progress?: DeployProgress, ): Promise< Result< { serviceEndpointDomain: string; previousVersionId: string | null; previousVersionAction: "stopped" | "destroyed" | null; }, DeployError > > { progress?.onPromoteStart?.(); const promoteResult = await this.#api.promoteService( serviceId, { versionId: newVersionId }, signal, ); if (promoteResult.isErr()) { progress?.onPromoteFailed?.(promoteResult.error); progress?.onCleanupDanglingVersion?.(newVersionId); await this.#cleanupDanglingVersion(newVersionId, undefined, progress); return promoteResult; } const serviceEndpointDomain = promoteResult.value.serviceEndpointDomain; progress?.onPromoted?.(serviceEndpointDomain); let previousVersionAction: "stopped" | "destroyed" | null = null; if (previousVersionId && previousVersionId !== newVersionId) { progress?.onOldVersionStopping?.(previousVersionId); const stopResult = await this.#api.stopVersion(previousVersionId, signal); if (stopResult.isOk()) { const pollResult = await pollVersionStatus( this.#api, previousVersionId, { targetStatus: "stopped", timeoutSeconds: 30, pollIntervalMs: 1000, signal, }, ); if (pollResult.isOk()) { previousVersionAction = "stopped"; progress?.onOldVersionStopped?.(previousVersionId); if (destroyOldVersion) { progress?.onOldVersionDeleting?.(previousVersionId); const deleteResult = await this.#api.deleteVersion( previousVersionId, signal, ); if (deleteResult.isOk()) { previousVersionAction = "destroyed"; progress?.onOldVersionDeleted?.(previousVersionId); } else { progress?.onOldVersionDeleteFailed?.(previousVersionId); } } } else { progress?.onOldVersionStopFailed?.(previousVersionId); } } else { progress?.onOldVersionStopFailed?.(previousVersionId); } } return Result.ok({ serviceEndpointDomain, previousVersionId, previousVersionAction, }); } async #cleanupDanglingVersion( versionId: string, signal?: AbortSignal, progress?: DeployProgress, ): Promise { const stopResult = await this.#api.stopVersion(versionId, signal); if (stopResult.isErr()) { progress?.onCleanupDanglingVersionFailed?.(versionId); return; } const pollResult = await pollVersionStatus(this.#api, versionId, { targetStatus: "stopped", timeoutSeconds: 30, pollIntervalMs: 1000, signal, }); if (pollResult.isErr()) { progress?.onCleanupDanglingVersionFailed?.(versionId); return; } const deleteResult = await this.#api.deleteVersion(versionId, signal); if (deleteResult.isErr()) { progress?.onCleanupDanglingVersionFailed?.(versionId); return; } progress?.onCleanupDanglingVersionComplete?.(versionId); } #checkAborted(signal?: AbortSignal): Result { try { signal?.throwIfAborted(); return Result.ok(undefined); } catch { return Result.err(new CancelledError()); } } async #resolveDeployTarget(options: { projectId?: string; serviceId?: string; serviceName?: string; region?: string; signal?: AbortSignal; interaction?: DeployInteraction; }): Promise< Result< { projectId: string; serviceId: string; serviceName: string; region: string; branchId: string | null; isNewService: boolean; }, | MissingArgumentError | CancelledError | AuthenticationError | ApiError | InvalidOptionsError > > { const self = this; return Result.gen(async function* () { if (options.serviceId) { const service = yield* Result.await( self.#api.getService(options.serviceId, options.signal), ); if (!service.projectId) { return Result.err(new MissingArgumentError({ field: "projectId" })); } const requestedProjectId = options.projectId?.replace(/^proj_/, ""); const serviceProjectId = service.projectId.replace(/^proj_/, ""); if ( requestedProjectId !== undefined && requestedProjectId !== serviceProjectId ) { return Result.err( new InvalidOptionsError({ message: `Service ${service.id} belongs to project ${service.projectId}, but project ${options.projectId} was provided`, }), ); } return Result.ok({ projectId: service.projectId, serviceId: service.id, serviceName: service.name ?? service.id, region: service.region.id, branchId: service.branchId ?? null, isNewService: false, }); } let projectId = options.projectId; if (!projectId) { const projects = yield* Result.await( self.#api.listProjects(options.signal), ); const projectInfos = projects.map( (p): ProjectInfo => ({ id: p.id, name: p.name, defaultRegion: p.defaultRegion ?? undefined, }), ); if (!options.interaction?.selectProject) { return Result.err(new MissingArgumentError({ field: "projectId" })); } projectId = await options.interaction.selectProject(projectInfos); } if (options.serviceName && options.region) { const response = yield* Result.await( self.#api.createProjectService( projectId, { displayName: options.serviceName, regionId: options.region, }, options.signal, ), ); return Result.ok({ projectId, serviceId: response.id, serviceName: response.name, region: response.region.id, branchId: response.branchId ?? null, isNewService: true, }); } const services = yield* Result.await( self.#api.listProjectServices(projectId, options.signal), ); const serviceInfos = services.map( (s): ServiceInfo => ({ id: s.id, name: s.name, region: s.region.id, projectId: s.projectId, }), ); if (!options.interaction?.selectService) { return Result.err(new MissingArgumentError({ field: "serviceId" })); } const selectedServiceId = await options.interaction.selectService(serviceInfos); if (selectedServiceId !== null) { const selected = services.find((s) => s.id === selectedServiceId); invariant( selected, "selectService returned service ID not in the list", ); const service = yield* Result.await( self.#api.getService(selectedServiceId, options.signal), ); return Result.ok({ projectId, serviceId: selectedServiceId, serviceName: service.name ?? selected.name, region: service.region.id, branchId: service.branchId ?? null, isNewService: false, }); } let serviceName = options.serviceName; if (!serviceName) { if (!options.interaction?.provideServiceName) { return Result.err(new MissingArgumentError({ field: "serviceName" })); } serviceName = await options.interaction.provideServiceName(); } let region = options.region; if (!region) { if (!options.interaction?.selectRegion) { return Result.err(new MissingArgumentError({ field: "region" })); } region = await options.interaction.selectRegion(REGIONS); } const response = yield* Result.await( self.#api.createProjectService( projectId, { displayName: serviceName, regionId: region, }, options.signal, ), ); return Result.ok({ projectId, serviceId: response.id, serviceName: response.name, region: response.region.id, branchId: response.branchId ?? null, isNewService: true, }); }); } async #applyEnvVars( projectId: string, branchId: string | null, envVars: Record | undefined, signal?: AbortSignal, ): Promise> { if (!envVars || Object.keys(envVars).length === 0) { return Result.ok(undefined); } const self = this; return Result.gen(async function* () { const scope = yield* Result.await( self.#resolveEnvironmentVariableScope(projectId, branchId, signal), ); for (const [key, value] of Object.entries(envVars)) { yield* self.#checkAborted(signal); const existing = yield* Result.await( self.#api.listEnvironmentVariables({ ...scope, key }, signal), ); const current = existing[0]; if (value === null) { if (current) { yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.deleteEnvironmentVariable(current.id), ); } continue; } if (current) { yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.updateEnvironmentVariable(current.id, { value }), ); continue; } yield* self.#checkAborted(signal); // Do not pass signal to mutating transport; aborting it can make remote completion ambiguous. yield* Result.await( self.#api.createEnvironmentVariable({ ...scope, key, value }), ); } return Result.ok(undefined); }); } async #resolveEnvironmentVariableScope( projectId: string, branchId: string | null, signal?: AbortSignal, ): Promise< Result< | { projectId: string; class: "production" } | { projectId: string; class: "preview"; branchId: string; }, ApiRequestError > > { if (!branchId) { return Result.ok({ projectId, class: "production" }); } const branch = await this.#api.getBranch(branchId, signal); if (branch.isErr()) return branch; if (branch.value.role === "preview") { return Result.ok({ projectId, class: "preview", branchId }); } return Result.ok({ projectId, class: "production" }); } async #uploadArtifactResult( url: string, body: Uint8Array, signal?: AbortSignal, ): Promise> { return Result.tryPromise({ try: async () => { await uploadArtifact(url, body, signal); }, catch: (e) => { if (signal?.aborted) return new CancelledError(); return new ArtifactError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); } } /** * Sort versions: running/provisioning first, then by createdAt descending. */ function versionSortComparator(a: VersionInfo, b: VersionInfo): number { const statusPriority = (s: string) => s === "running" ? 0 : s === "provisioning" ? 1 : 2; const aPriority = statusPriority(a.status); const bPriority = statusPriority(b.status); if (aPriority !== bPriority) return aPriority - bPriority; return b.createdAt.localeCompare(a.createdAt); }