🔥

d1 + prisma + kysely-prisma の環境を作る

2023/09/04に公開

注意: 本記事で扱う d1 や miniflare v3 はまだ安定してないので、将来的にこの記事のコードは動かなくなる可能性が高い。

大部分を次の記事を参考にしている。

https://zenn.dev/chimame/articles/23aafcc2e70f33

が、現時点で色々動いたり動かなかったりしたので、だいぶアレンジしている。たぶん .wrangler/state/v3 のローカルDBのパスが頻繁に変わることが予想される。

この記事は何

  • prisma で d1 を migrations したい
  • d1 は kysely-d1 を prisma の型定義を使って動かす
  • wrangler dev/pages 以外の環境からでもテスト用に wokrerd から d1 の binding を取得する

最終的にはこれが動く

src/worker.ts
import type { D1Database } from "@cloudflare/workers-types";
import type { DB } from "./db/types"; // generated by prisma
import { D1Dialect } from "kysely-d1";
import { Kysely } from "kysely";
export default {
 async fetch(req: Request, env: {DB: D1Database}) {
    const db = new Kysely<DB>({
      dialect: new D1Dialect({ database: env.DB }),
    });
    const users = await db.selectFrom("User").selectAll().execute();
    return Response.json(users);
  }
}

Why

cloudflare workers/pages 環境では @prisma/client は使えない。 @prisma/client/edge は PrismaDataPloxy 経由で外部に立てたサーバーと通信する用途で、スタンドアロンで動くものではない。

とはいえ、いつか動くようになる気はするので、prisma で migration と型定義だけ管理させて、kysely からd1を実行する。drizzle の方が API は好みなのだが、この辺の連携がない。

d1 のセットアップ

諸々のインストール(足りないものは適宜足す)

$ npm install kysely kysely-prisma kysely-d1 prisma wrangler -D

まずは d1 のデータベースを作成する

# wrangler のセットアップは略
npx wrangler d1 create mydb

wrangler.toml ファイルがない場合は作成し、以下を追記

wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "mydb"
database_id = "<database_id>"
migrations_dir = ".wrangler/migrations"

database_id は環境ごとに違うことに注意。migrations_dir については後述。

次に prisma をセットアップする

$ npm install prisma --save-dev
$ npx prisma init --datasource-provider sqlite

これで sqlite 向けに prisma/schema.prisma が生成される。
今回、これはそのまま使わない。kysely adatper 用にセットアップする

prisma-kysely のセットアップ

kysely で出力する用にモデルを定義する

prisma/schema.prisma
datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator kysely {
  provider     = "prisma-kysely"
  output       = "../src/db"
  fileName     = "types.ts"
  enumFileName = "enums.ts"
}

model User {
  id    String  @id @default(dbgenerated("(uuid())"))
  name  String
  posts Post[]
  createdAt Int @default(dbgenerated("(unixepoch())"))
}

model Post {
  id      String  @id @default(dbgenerated("(uuid())"))
  content String
  userId  String?
  user    User?  @relation(fields: [userId], references: [id])
  createdAt Int @default(dbgenerated("(unixepoch())"))
}

注意点として、 kysely-prisma でデータベースに生成させたい値を定義するには、dbgenerated() を使う必要があった。 default に prisma の uuid() 等を直接使うと、kysely の型レベルでは Optional にならない。

次に、.env に prisma の接続先を書く

DATABASE_URL="file:../.wrangler/state/v3/d1/<database_id>/db.sqlite"

これは wrangler d1 がローカルにsqlite を生成するパスで、prisma と wrangler が同じDBを見るようにする。

ローカルのDBに対してマイグレーションを実行する。

$ npx prisma migrate dev --create-only
# prisma/migrations/*/migration.sql を確認する
$ npx prisma migrate deploy # 適用
$ npx prisma generate # 型定義ファイルの生成

--create-only を付けてチェックを挟んでから実行するようにしている。というのも prisma は一部 d1 で実行できない sql を生成するので、これを手作業で修正する必要があった。具体的には PRAGMA foreign_keys_check; を削除することが多い。

prisma/migrations/*/migration.sql を d1 へ適用

prisma/migrations 以下を d1 に適用したいのだが、 wranglgler の migrations_dir はファイルがフラットに展開されることを期待しているため、prisma/migrations/*/migration.sql のパターンで展開されている prisma のディレクトリをそのまま入力にできない。

というわけで、 .wrangler/migrations/*.sql に配置し直す zx スクリプトを書いた。ついでに wrangler.toml の DB 定義からパラメータを引っ張り出す処理も書いた。

scripts/migration.mjs
// npm install zx glob @iarra/toml -D
import fs from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
import { globSync } from "glob";
import { parse } from "@iarna/toml";

const opts = parseArgs({
  options: {
    wrangler: {
      type: "string",
      short: 'w',
      default: "./wrangler.toml",
    },
    "database-name": {
      type: "string",
      short: 'n'
    },
    prisma: {
      type: "string",
      default: "./prisma/migrations",
    },
    production: {
      type: "boolean",
      short: 'p'
    },
  },
  allowPositionals: true,
});

const cwd = process.cwd();
const wrangler = loadWranglerConfig(opts.values.wrangler);
const database = wrangler.d1_databases.find((d1_database) => d1_database.database_name === opts.values["database-name"])
  ?? wrangler.d1_databases[0];
if (!database) {
  console.error("No database found");
  process.exit(1);
} else {
  console.log("[d1 database detected]", database);
}

const prismaDir = opts.values.prisma ?? './prisma/migrations';
const migrationDir = database.migrations_dir ?? '.wrangler/migrations';
await $`rm -rf ${migrationDir} && mkdir -p ${migrationDir}`;

for (const prismaPath of globSync(`${prismaDir}/*/migration.sql`, { cwd })) {
  const migrationName = prismaPath.split("/")[2]; // prisma/migrations/<here>/migration.sql
  const migrationPath = `${migrationDir}/${migrationName}.sql`
  await $`cp ${prismaPath} ${migrationPath}`
}

if (opts.values.production) {
  await $`pnpm wrangler d1 migrations apply ${database.database_name}`;
} else {
  await $`pnpm wrangler d1 migrations apply ${database.database_name} --local`;
}

/**
 * @param wranglerPath {string | undefined}
 * @return {{
 *  d1_databases: Array<{
 *   database_name: string,
 *   binding: string,
 *   database_id: string,
 *   migrations_dir?: string,
 * }>
 * }}
 */
function loadWranglerConfig(wranglerPath) {
  const wranglerTomlPath = path.join(cwd, wranglerPath);
  const raw = fs.readFileSync(wranglerTomlPath, "utf-8");
  return parse(raw);
}

これで期待している .wrangler/migrations 以下にファイルを展開してから wrangler のマイグレーションを実行する。

$ npx zx scripts/migration.mjs --production
[d1 database detected] {
  binding: 'DB',
  database_name: 'mydb2',
  database_id: '837018aa-cde8-49ce-ac2a-68225f45dea8',
  migrations_dir: '.wrangler/migrations'
}
$ rm -rf .wrangler/migrations && mkdir -p .wrangler/migrations
$ cp prisma/migrations/20230904073804_rename/migration.sql .wrangler/migrations/20230904073804_rename.sql
$ cp prisma/migrations/20230904072431_user_at/migration.sql .wrangler/migrations/20230904072431_user_at.sql
$ cp prisma/migrations/20230904072218_created/migration.sql .wrangler/migrations/20230904072218_created.sql
$ cp prisma/migrations/20230904070604_fix/migration.sql .wrangler/migrations/20230904070604_fix.sql
$ cp prisma/migrations/20230904063309_post/migration.sql .wrangler/migrations/20230904063309_post.sql
$ cp prisma/migrations/20230904055000_init/migration.sql .wrangler/migrations/20230904055000_init.sql
$ pnpm wrangler d1 migrations apply mydb2 --local

このスクリプトはローカルに向けても実行できるように作ったが、たぶんローカルでは prisma migrate deploy で統一して、本番用でのみ使ったほうがよさそう。というのも prisma も d1 いずれも専用のテーブルに migrations の中間状態を書き込むので、その不整合を避けたい。

これで実行できれば成功。エラーが発生する場合、未対応の命令を使っていないか探す。自分は wrangler d1 execute db-name --command='...' というようなコマンドで本番のDBに対して行ごとに実行して確かめていた。

初回移行のマイグレーションも、 prisma/schema.prisma を変更してから mirgrate dev を実行する。

注意: d1 のローカルと本番で挙動が違う

prisma でテーブル定義を変更する時は、 PRAGMA foreign_keys_check; を含んだ SQL を生成するのだが、これが本番環境の d1 で未対応なのに ローカル環境の prisma migrate では通ってしまうので、削除する必要がある。

d1 が対応している PRAGMA はここを参照。

https://developers.cloudflare.com/d1/platform/client-api/#pragma-statements

また、sql のコメントのパース処理に不備があり、; で終わるコメントアウトが空行の statement 扱いと判定されて、実行に失敗する。

-- comment;
select 1;

雑にコメントアウトすると大抵動かなくなるのに注意。

あとは prisma-sqlite は生成しないはずだが、 ALTER TABLE RENAME COLUMN も未対応。

ちなみに d1_migrations というテーブルで、どのマイグレーションが適用されているかわかる。

$ npx wrangler d1 execute mydb2 --command='select * from d1_migrations'
┌────┬────────────────────────────┬─────────────────────┐
│ id │ name                       │ applied_at          │
├────┼────────────────────────────┼─────────────────────┤
│ 1  │ 20230904055000_init.sql    │ 2023-09-04 06:28:34 │
├────┼────────────────────────────┼─────────────────────┤
│ 2  │ 20230904063309_post.sql    │ 2023-09-04 06:35:40 │
├────┼────────────────────────────┼─────────────────────┤
│ 3  │ 20230904070604_fix.sql     │ 2023-09-04 07:07:41 │
├────┼────────────────────────────┼─────────────────────┤
│ 4  │ 20230904072218_created.sql │ 2023-09-04 07:23:49 │
├────┼────────────────────────────┼─────────────────────┤
│ 5  │ 20230904072431_user at.sql │ 2023-09-04 07:25:36 │
├────┼────────────────────────────┼─────────────────────┤
│ 6  │ 20230904073804_rename.sql  │ 2023-09-04 07:40:46 │
├────┼────────────────────────────┼─────────────────────┤
│ 7  │ 20230904072431_user_at.sql │ 2023-09-04 08:43:14 │
└────┴────────────────────────────┴─────────────────────┘

前に試しにこのテーブルを削除したら wrangler d1 migrations apply が全く動かなくなって痛い目を見た

cloudflare workers/pages 環境から kysely-d1 を呼ぶ

ここからやっとサーバー内で d1 を実行していく。

npx prisma generate で次のような型定義ファイルが生成されている。

src/db/types.ts
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
  ? ColumnType<S, I | undefined, U>
  : ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;

export type Post = {
    id: Generated<string>;
    content: string;
    userId: string | null;
    createdAt: Generated<number>;
};
export type User = {
    id: Generated<string>;
    name: string;
    createdAt: Generated<number>;
};
export type DB = {
    Post: Post;
    User: User;
};

この型情報を kysely に渡して初期化する。その際に d1 インスタンスを kysely-d1 でラップしてやる。

簡単な worker で呼び出す例。

src/worker.ts
import type { D1Database } from "@cloudflare/workers-types";
import type { DB } from "./db/types";
import { D1Dialect } from "kysely-d1";
import { Kysely } from "kysely";

type Env = {
  DB: D1Database;
}

export default {
 async fetch(req: Request, env: Env) {
    const db = new Kysely<DB>({
      dialect: new D1Dialect({ database: env.DB }),
    });
    const users = await db.selectFrom("User").selectAll().execute();
    return Response.json(users);
  }
}

これで prisma から生成した型定義を kysely で使うことができる。

将来的に prisma が動くなら、そのまま prisma/client に移行するのも簡単なはず。

おまけ: ローカルの node 環境で d1 をテストする

wrangler(workerd) のランタイム外から d1 インスタンスを初期化したい。要は jest-environment-miniflare 相当のことがしたい。が、 miniflare v2 が現在 deprecated になっていて、jest-environment-miniflare も同様。

ドキュメントが全然ない miniflare v3 を使って、d1 インスタンスを生成する方法を探して、最終的にこんな感じになった。

// npm install miniflare -D # 3 系が入ることをチェック
import { Miniflare } from "miniflare";
const D1_PERSIST_ENDPOINT = ".wrangler/state/v3/d1";

const database_id = "<database_id>";
const mf = new Miniflare({
  workers: [
    {
      name: "main",
      modules: true,
      script: `export default {
        async fetch(req, env, ctx) {
          return new Response("ok");
        }
      }
      `,
      d1Databases: { DB: database_id },
    },
  ],
  d1Persist: D1_PERSIST_ENDPOINT,
  d1Databases: [database_id]
});
// const response = await mf.dispatchFetch("http://localhost:8787/");
// console.log(await response.text()); // ok
const bindings = await mf.getBindings("main");
const db = bindings.DB;
await db.exec("CREATE TABLE IF NOT EXISTS requests (url TEXT)");
await db.exec("INSERT INTO requests (url) VALUES ('aaa')");
const prepared = await db.prepare(`select * from "requests"`);
const res = await prepared.all();
console.log(res);
await mf.dispose();

miniflare v3 は内部的には workerd へのブリッジになっている。まず、何か適当な ModuleWorker を作り、その Binding を取得。トップレベルの d1Databases に対して、worker 側からその database_id を指定する感じになる。このとき、DB実体のパスを wrangler d1 の期待するパスに合わせる。

つまり .wrangler/state/v3/d1/<database_id>/db.sqlite に向くように辻褄を合わせている。

今回は d1 だが、 r2 やその他の binding も作れる。

これは最終的に cloudflare から公式に提供される機能だと思うが、現状は色々と手作業でやる必要がある。

感想

  • kysely で prisma の migrationと型情報を使うことができるようになったが本当は prisma 直接使いたい...
  • d1 のエラーメッセージがこなれてなくて、何でエラーが起きたかの調査がしんどい
  • これ作ってる間に production の d1 が落ちてて、何が原因かわからなかった

Discussion