空雲 Blog

Eye catchCloudflareD1へNode.jsのPrismaからアクセスする

publication: 2024/04/03
update:2024/04/03

Prisma の D1 対応

下記の通り Prisma で Cloudflare の D1 がサポートされました。

https://www.prisma.io/blog/build-applications-at-the-edge-with-prisma-orm-and-cloudflare-d1-preview

ということで早速試してみます。
ただ、Prisma を使って 普通に Cloudflare 上から D1 にアクセスするのは誰でも簡単にできるので、世界で誰もやってなさそうな Node.js からアクセスする方法紹介します。

Worker の作成

まずは Cloudflare 上に D1 にアクセスするための Worker を作成します。これは Prisma をリモートアクセスさせるための Proxy です。QueryEngine だけ Workers 上で動かし、その他の部分は Node.js で行います。

https://github.com/SoraKumo001/prisma-accelerate-workers-d1

このプログラムを Workers にデプロイし、d1_databases をバインディング、シークレットを設定します。その後、以下のコマンドで API キーを作成します。これで準備完了です。

1npx prisma-accelerate-local -s SECRET -m DB

ここで作った API キーと Workers の URL を使えば、Node.js 上の Prisma から D1 にアクセス出来るようになります。

余談ですが、prisma@5.12.0の D1 などの Adapter には以下の PR で混入した Node.js のutilがバンドルされてしまう問題があって、本来いらないはずの Node.js のランタイムを必要とします。

https://github.com/prisma/prisma/pull/23013

これがどういうことかというと、最初の記事で紹介されている Workers の設定のnodejs_compat(Workers 上のランタイムで 0 サイズ)では動かず、node_compat(Polyfill で容量を食う)の方が必要となります。これを回避する方法は前述の github の README に記載しています。

Node.js からのアクセス

https://github.com/SoraKumo001/prisma-d1-test

  • prisma/schema.prisma

directUrl はマイグレーションファイルの生成に必要なのでダミーで指定しておきますが、実際に中にデータを入れることはありません。

1generator client {
2 provider = "prisma-client-js"
3}
4
5datasource db {
6 provider = "sqlite"
7 url = env("DATABASE_URL")
8 directUrl = "file:./dev.db"
9}
10
11model Role{
12 id String @id @default(uuid())
13 name String @unique
14 users User[]
15 createdAt DateTime @default(now())
16 updatedAt DateTime @updatedAt
17}
18
19model User {
20 id String @id @default(uuid())
21 email String @unique
22 name String @default("User")
23 posts Post[]
24 roles Role[]
25 createdAt DateTime @default(now())
26 updatedAt DateTime @updatedAt
27}
28
29model Post {
30 id String @id @default(uuid())
31 published Boolean @default(false)
32 title String @default("New Post")
33 content String @default("")
34 author User? @relation(fields: [authorId], references: [id])
35 authorId String?
36 categories Category[]
37 createdAt DateTime @default(now())
38 updatedAt DateTime @updatedAt
39 publishedAt DateTime @default(now())
40}
41
42model Category {
43 id String @id @default(uuid())
44 name String
45 posts Post[]
46 createdAt DateTime @default(now())
47 updatedAt DateTime @updatedAt
48}

  • tools/migrate.ts

D1 に対して、リモートでマイグレーションを行うために適当に作ったコードです。Prisma のマイグレーションファイルを一つのフォルダに集約し、DB 名から UUID を取得して、その UUID を使ってマイグレーションを行います。手動でやると面倒なので、これを使って自動化します。

1import fs from "fs";
2import { exec } from "child_process";
3import { promisify } from "util";
4import path from "path";
5import os from "os";
6
7const srcPath = "prisma/migrations";
8const distPath = fs.mkdtempSync(path.join(os.tmpdir(), "prisma-migrations"));
9const DB = process.env.DB_NAME!;
10
11const execAsync = promisify(exec);
12
13type D1List = {
14 uuid: string;
15 name: string;
16 version: string;
17 created_at: string;
18};
19
20export const getD1List = async () => {
21 const list = await execAsync("wrangler d1 list --json").then(
22 ({ stdout }) => JSON.parse(stdout) as D1List[]
23 );
24 return list;
25};
26
27export const migrationD1 = async (dbName: string, dir: string) => {
28 return execAsync(
29 `wrangler d1 migrations apply --remote ${dbName} -c ${dir}/wrangler.toml`
30 ).then(({ stdout, stderr }) => stderr || stdout);
31};
32
33const main = async () => {
34 if (fs.existsSync(distPath)) {
35 fs.rmSync(distPath, { recursive: true });
36 }
37
38 fs.mkdirSync(distPath, { recursive: true });
39 const migrations = fs.readdirSync(srcPath, { withFileTypes: true });
40 migrations
41 .filter((v) => {
42 return v.isDirectory();
43 })
44 .sort((a, b) => (a.name < b.name ? -1 : 1))
45 .forEach(({ name }) => {
46 const sql = fs.readFileSync(`${srcPath}/${name}/migration.sql`, "utf8");
47 fs.writeFileSync(`${distPath}/${name}.sql`, sql);
48 });
49
50 const list = await getD1List();
51 const uuid = list.find((v) => v.name === DB)?.uuid;
52 if (uuid) {
53 fs.writeFileSync(
54 `${distPath}/wrangler.toml`,
55 `[[d1_databases]]
56binding = "DB"
57database_name = "${DB}"
58database_id ="${uuid}"
59migrations_dir = "./"`
60 );
61 }
62 const result = await migrationD1(DB, distPath);
63 console.log(result);
64 fs.rmSync(distPath, { recursive: true });
65};
66
67main();

  • .env

Workers の URL と API キーを設定します。また、マイグレーションを行うための DB 名を設定します。こちらは先程のスクリプトで利用します。

1# Address of installed Workers
2DATABASE_URL=prisma://xxxxx.workers.dev?api_key=xxxxxx
3# For Migration
4DB_NAME=xxxx

  • package.json

yarn prisma:migrateを実行すると、Prisma がマイグレーションファイルを作成し、それを Workers に送信してマイグレーションを行います。next-execは環境変数を設定してから実行するためのツールです。

1{
2 "name": "prisma-d1-test",
3 "version": "1.0.0",
4 "main": "index.js",
5 "license": "MIT",
6 "scripts": {
7 "start": "next-exec -- tsx src",
8 "prisma:migrate": "prisma migrate dev && next-exec -- tsx tools/migrate.ts && prisma generate --no-engine"
9 },
10 "dependencies": {
11 "@prisma/client": "^5.12.0"
12 },
13 "devDependencies": {
14 "@types/node": "^20.12.3",
15 "next-exec": "^1.0.0",
16 "prisma": "^5.12.0",
17 "tsx": "^4.7.1",
18 "typescript": "^5.4.3",
19 "wrangler": "^3.44.0"
20 }
21}

  • src/index.ts

こちらがメインのコードです。Prisma を使って D1 にアクセスしデータの読み書きをしています。通常の Node.js のコードと全く代わりません。DATABASE_URL にprisma://xxxxを指定している時点で、D1 の設定作業は終わっています。

1import { PrismaClient } from "@prisma/client";
2
3const formatNumber = (num: number) => {
4 return num.toString().padStart(2, "0");
5};
6
7const main = async () => {
8 const prisma = new PrismaClient();
9 const roles = await prisma.role.count().then(async (count) => {
10 if (!count) {
11 return Promise.all(
12 [
13 {
14 name: "ADMIN",
15 },
16 { name: "USER" },
17 ].map((data) => {
18 return prisma.role.create({
19 data,
20 });
21 })
22 );
23 }
24 return prisma.role.findMany();
25 });
26
27 if (roles === undefined) {
28 throw new Error("roles is undefined");
29 }
30 const ROLES = Object.fromEntries(roles.map((v) => [v.name, v.id] as const));
31
32 const users = await prisma.user.count().then(async (count) => {
33 if (!count) {
34 return Promise.all(
35 [
36 {
37 name: "admin",
38 email: "admin@example.com",
39 roles: {
40 connect: [
41 {
42 id: ROLES["ADMIN"],
43 },
44 { id: ROLES["USER"] },
45 ],
46 },
47 },
48 {
49 name: "example",
50 email: "example@example.com",
51 roles: { connect: [{ id: ROLES["USER"] }] },
52 },
53 ].map((data) => {
54 return prisma.user.create({
55 data,
56 });
57 })
58 );
59 }
60 return prisma.user.findMany();
61 });
62
63 // add category
64 const categories = await prisma.category.count().then(async (count) => {
65 if (!count) {
66 return Promise.all(
67 Array(10)
68 .fill(0)
69 .map((_, i) => ({ name: `Category${formatNumber(i + 1)}` }))
70 .map((data) =>
71 prisma.category.create({
72 data,
73 })
74 )
75 );
76 }
77 return prisma.category.findMany();
78 });
79
80 // add post
81 await prisma.post.count().then(async (count) => {
82 if (!count) {
83 for (let i = 0; i < 30; i++) {
84 await prisma.post.create({
85 data: {
86 title: `Post${formatNumber(i + 1)}`,
87 content: `Post${formatNumber(i + 1)} content`,
88 authorId: users[1].id,
89 published: i % 4 !== 0,
90 categories: {
91 connect: [
92 { id: categories[i % 2].id },
93 { id: categories[i % 10].id },
94 ],
95 },
96 },
97 });
98 }
99 }
100 });
101
102 console.log(
103 JSON.stringify(
104 await prisma.post.findMany({
105 include: { author: true, categories: true },
106 }),
107 undefined,
108 2
109 )
110 );
111};
112main();

まとめ

Prisma で D1 がサポートされたので、Node.js からアクセスする方法を紹介しました。これによって Cloudflare の D1 が、サーバーレスな DB となります。