1import * as Effect from "effect/Effect";
2import {
3 type GetServiceDeploymentsResponse,
4 getServiceDeployments,
5} from "@distilled.cloud/prisma/management";
6import { PrismaPaginationError } from "./Pagination.ts";
7
8/** Prove that a deployment belongs to an App before mutating or deleting it. */
9export const ensureDeploymentMembership = Effect.fn(function* (
10 appId: string,
11 deployment: { id: string; foundryVersionId: string },
12 knownLatestDeploymentId?: string | null,
13) {
14 if (knownLatestDeploymentId === deployment.id) return;
15 // Distilled emits the cursor-paginated list operations as plain ops, so
16 // callers walk `pagination` themselves (see `src/Neon/Project.ts`).
17 const deployments: GetServiceDeploymentsResponse["data"][number][] = [];
18 let cursor: string | undefined;
19 while (true) {
20 const page = yield* getServiceDeployments(
21 cursor === undefined
22 ? { serviceId: appId }
23 : { serviceId: appId, cursor },
24 );
25 deployments.push(...page.data);
26 const nextCursor = page.pagination.nextCursor;
27 if (!page.pagination.hasMore) break;
28 if (nextCursor === null) {
29 return yield* Effect.fail(
30 new PrismaPaginationError({
31 message:
32 "Invalid Prisma Management API pagination response from getServiceDeployments: hasMore was true without a non-empty nextCursor",
33 }),
34 );
35 }
36 cursor = nextCursor;
37 }
38 const matches = deployments.filter(
39 (candidate) => candidate.id === deployment.id,
40 );
41 if (
42 matches.length !== 1 ||
43 matches[0]?.foundryVersionId !== deployment.foundryVersionId
44 ) {
45 return yield* Effect.fail(
46 new Error(
47 `Prisma deployment '${deployment.id}' with Foundry version '${deployment.foundryVersionId}' is not uniquely owned by App '${appId}'. Refusing to start, promote, or delete a mismatched deployment.`,
48 ),
49 );
50 }
51});