d1 + prisma + kysely-prisma の環境を作る
注意: 本記事で扱う d1 や miniflare v3 はまだ安定してないので、将来的にこの記事のコードは動かなくなる可能性が高い。
大部分を次の記事を参考にしている。
が、現時点で色々動いたり動かなかったりしたので、だいぶアレンジしている。たぶん .wrangler/state/v3
のローカルDBのパスが頻繁に変わることが予想される。
この記事は何
- prisma で d1 を migrations したい
- d1 は kysely-d1 を prisma の型定義を使って動かす
- wrangler dev/pages 以外の環境からでもテスト用に wokrerd から d1 の binding を取得する
最終的にはこれが動く
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 ファイルがない場合は作成し、以下を追記
[[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 で出力する用にモデルを定義する
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 定義からパラメータを引っ張り出す処理も書いた。
// 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 はここを参照。
また、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
で次のような型定義ファイルが生成されている。
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 で呼び出す例。
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