Lambda で Prisma 使って Migrate する

## やりたいこと

AWS で RDS 作ったけど、テーブル作ったりするのどうしよー
という事で、今回は [Prisma](https://www.prisma.io/) を AWS Lambda に入れて、migrate を実行してみました。

1. ローカルで `prisma migrate dev` を実行して migration ファイルを作成
2. AWS Lambda で `prisma migrate deploy` を実行して Amazon RDS に適応

ということをやろうと思います。こうすることで、

- AWS 上にサーバーを立てることなく DB のテーブル作成が可能
- Prisma の migration ファイルを管理することで、複数人での開発や、他環境への適応も楽

になるはず。
ちなみに、私は Amplify を使って Lambda を作成しました。

## Lambda のコード

RDS のユーザ名、パスワードは Secrets Manager にある前提です。
以下の環境変数の設定が必要です。

- DATABASE_SECRET_ARN: Secrets Manager の ARN
- DATABASE_ENGINE: データベースエンジン名 (postgresql とか)
- DATABASE_HOST: RDS のエンドポイント
- DATABASE_PORT: RDS のポート番号
- DATABASE_NAME: データベース名

```index.ts
import { execFile } from 'child_process';
import * as path from 'path';
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
import { type Handler, type Context } from 'aws-lambda';

type Event = {
  typeName: string;
  fieldName: string;
  arguments?: {
    command?: string;  // deploy, reset or resolve
    option?: string;   // resolve の時に migration 名を指定
  };
};

type DatabaseSecrets = {
  username: string;
  password: string;
};

const client = new SecretsManagerClient();

/* SecretsManager から username と password を取得 */
const getDatabaseSecrets = async (
  secretName: string,
): Promise<DatabaseSecrets | undefined> => {
  const response = await client.send(
    new GetSecretValueCommand({
      SecretId: secretName,
      VersionStage: 'AWSCURRENT',
    }),
  );
  if (response.SecretString == null) return;

  const secret = JSON.parse(response.SecretString) as DatabaseSecrets;
  return secret;
};

/* prisma コマンドを実行 */
const execMigrate = async (command: string, options: string[]) => {
  const exitCode = await new Promise<number>((resolve, _reject) => {
    execFile(
      path.resolve('./node_modules/prisma/build/index.js'),
      ['migrate', command].concat(options),
      (error, stdout, _stderr) => {
        console.log(stdout);
        
        if (error != null) {
          console.error(
            `prisma migrate ${command} exited with error ${error.message}`,
          );
          resolve(Number(error.code) ?? 1);
        } else {
          resolve(0);
        }
      },
    );
  });

  if (exitCode !== 0)
    throw Error(`prisma migrate ${command} failed with exit code ${exitCode}`);
};

export const handler: Handler = async (event: Event, _context: Context) => {
  console.log(`EVENT: ${JSON.stringify(event)}`);

  // 環境変数チェック
  if (process.env.DATABASE_SECRET_ARN == null) {
    throw new Error('DATABASE_SECRET_ARN is not defined.');
  }
  if (process.env.DATABASE_ENGINE == null) {
    throw new Error('DATABASE_ENGINE is not defined.');
  }
  if (process.env.DATABASE_HOST == null) {
    throw new Error('DATABASE_HOST is not defined.');
  }
  if (process.env.DATABASE_PORT == null) {
    throw new Error('DATABASE_PORT is not defined.');
  }
  if (process.env.DATABASE_NAME == null) {
    throw new Error('DATABASE_NAME is not defined.');
  }

  // SecretsManager から username, password を取得
  const secretArn = process.env.DATABASE_SECRET_ARN;
  const secrets = await getDatabaseSecrets(secretArn);
  if (secrets == null) {
    throw new Error('Cannot get database secrets.');
  }

  // URL セーフにする
  const username = encodeURIComponent(secrets.username);
  const password = encodeURIComponent(secrets.password);
  
  // Prisma コマンドで使う環境変数を設定
  const engine = process.env.DATABASE_ENGINE;
  const host = process.env.DATABASE_HOST;
  const port = process.env.DATABASE_PORT;
  const dbName = process.env.DATABASE_NAME;
  process.env.DATABASE_URL = `${engine}://${username}:${password}@${host}:${port}/${dbName}`;

  // 引数なしの場合は prisma migrate deploy を実行
  const command: string = event?.arguments?.command ?? 'deploy';
  let options: string[] = [];
  switch (command) {
    case 'reset':
      // prisma reset --force --skip-generate を実行
      options = ['--force', '--skip-generate'];
      break;
    case 'resolve':
      // prisma migrate resolve --rolled-back を実行
      if (event?.arguments?.option == null) {
        throw new Error('migration name is not defined.');
      }
      options = ['--rolled-back', event.arguments.option];
  }

  // Prisma コマンドを実行
  await execMigrate(command, options);
};

```

## Lambda 作成時の注意事項！

### `npm install` 時に環境変数を設定する

Prisma は `npm install` 時、環境に合わせた BINARY を取得します。
Lambda の実行環境は Amazon Linux なので、ローカルで `npm install` したものをアップロードしてしまうと BINARY が無いため動きません。
そこで、環境変数 `PRISMA_CLI_BINARY_TARGETS` に `rhel-openssl-3.0.x` を追加する必要があります。
Apple Silicon の Mac の場合は以下のようにして `npm install` を実行します。

```bash
PRISMA_CLI_BINARY_TARGETS=darwin-arm64,rhel-openssl-3.0.x npm install prisma
```

https://www.prisma.io/docs/orm/reference/environment-variables-reference#prisma_cli_binary_targets


## 使い方

事前に、ローカルで DB を立て、環境変数 `DATABASE_URL` を立てた DB 向けに設定しておく必要があります。

1. ローカルで `prisma/schema.prisma` を作成/編集
2. ローカルで `npx prisma migrate dev` を実行
    - `prisma/migrations/` に migration ファイルが追加される
3. 作成された migration ファイルを含めて AWS Lambda を作成
4. AWS Lambda を実行
    - `npx prisma migrate deploy` が Amazon RDS 向けに実行される


migration ファイルをアップロードするために、毎回 Lambda を更新するのがちょっと手間だなー