CloudflareのService Bindings RPC を使って、Bun + Hono な Pages と Workers を連携してみた

Cloudflare環境でアプリを作っていたところ、Cloudflare Workers(以降Workers) で Service Binding RPC が使えると知りました。

 
Cloudflare Pages (以降Pages) とWorkersの連携でも RPC が使えるのか気になりました。調べてみたところ、使えそうという記事がありました。

 
そこで、Workers と Pages の RPC による連携として

  • Workers は C3 で作成し、RPC用関数を定義
  • Pages は Hono で作成し、WorkersのRPC用関数から値をもらって表示

をためしてみたところ、色々ハマったことから、メモを残しておきます。

 
目次

 

環境

  • Windows 11 WSL2
  • Wrangler 3.60.3
  • Bun 1.1.13
  • Cloudflare Workers
    • C3 で生成
  • Cloudflare Pages
    • Hono 4.4.6
  • 開発向けツール
    • concurrently 8.2.2
      • ローカルで、PagesとWorkersを同時に起動するため
    • Biome 1.8.1
      • Linter & Formatter

   

Service Binding RPCを使わない、Pages と Workers を作成

まずは、ローカルでそれぞれが連携しない、Pages と Workers を作成してみます。

 

事前準備

全体で使うパッケージをインストールします。

$ bun add -d wrangler concurrently

$ bun add --dev --exact @biomejs/biome

 
今回のWorkersとPagesですが、Bunのworkspace機能を使ってモノレポにします。

 
具体的には、 packages の下に2つのディレクトリを用意します。

  • app-worker
    • Workers用。C3で作り、Bunで動作
  • app-page
    • Pages用。Honoで作り、Bunで動作

 
まずは package.json に Bun workspace 用の設定を追加します。

{
  "private": true,  // 追加
  "workspaces": ["packages/*"],  // 追加
  "dependencies": {},
  "devDependencies": {
    "biome": "^0.3.3",
    "concurrently": "^8.2.2",
    "wrangler": "^3.60.3"
  }
}

 
また、Bunのworkspaceを使うときのサブディレクトpackages も作成し、その中へ移動しておきます。

$ mkdir packages

$ cd packages/

 

Workersの作成

今回は Cloudflare C3 を使い、Workers を作成します。
Create projects with C3 CLI · Cloudflare Pages docs

必要最低限の実装だけあればよいので、

  • ディレクトリは ./app-worker
  • C3のテンプレートは "Hello World" Worker

とし、あとは任意な設定とします。

なお、 bun create 時に cloudflare@latest とすると、最新版を探しに行って帰ってこなかったことから、明示的に2024/6/15の最新バージョン 2.21.7 を指定しています。

$ bun create cloudflare@2.21.7

using create-cloudflare version 2.21.7

╭ Create an application with Cloudflare Step 1 of 3
│ 
├ In which directory do you want to create your application?
│ dir ./app-worker
│
├ What type of application do you want to create?
│ type "Hello World" Worker
│
├ Do you want to use TypeScript?
│ yes typescript
│
├ Copying template files 
│ files copied to project directory
│ 
├ Updating name in `package.json` 
│ updated `package.json`
│ 
├ Installing dependencies 
│ installed via `bun install`
│ 
├ Installing dependencies 
│ installed via `bun install`
│ 
╰ Application created 

╭ Configuring your application for Cloudflare Step 2 of 3
│ 
├ Installing wrangler A command line tool for building Cloudflare Workers 
│ installed via `bun install wrangler --save-dev`
│ 
├ Installing @cloudflare/workers-types 
│ installed via bun
│ 
├ Adding latest types to `tsconfig.json` 
│ skipped couldn't find latest compatible version of @cloudflare/workers-types
│ 
├ Retrieving current workerd compatibility date 
│ compatibility date 2024-06-14
│ 
╰ Application configured 

╭ Deploy with Cloudflare Step 3 of 3
│ 
├ Do you want to deploy your application?
│ no deploy via `bun run deploy`
│
├  APPLICATION CREATED  Deploy your application with bun run deploy
│ 
│ Navigate to the new directory cd app-worker
│ Run the development server bun run start
│ Deploy your application bun run deploy
│ Read the documentation https://developers.cloudflare.com/workers
│ Stuck? Join us at https://discord.cloudflare.com
│ 
╰ See you again soon! 

 
この時点で Workers ができているはずです。

そこで、 app-worker ディレクトリの中で bun run dev して、http://localhost:8787/ へアクセスしたところ、 Hello World! が表示されました。

 

Pages の作成

続いて、Honoを使ってPagesを作成します。

$ bun create hono app-page

create-hono version 0.7.1
✔ Using target directory … app-page
? Which template do you want to use? cloudflare-pages
✔ Cloning the template
? Do you want to install project dependencies? no
🎉 Copied project files
Get started with: cd app-page

 
上記ではインストールを選択しなかったので、改めて app-pageへ移動し、インストールを行います。

$ cd app-page

$ bun install
bun install v1.1.13 (bd6a6051)

+ @cloudflare/workers-types@4.20240614.0
+ @hono/vite-cloudflare-pages@0.4.0
+ @hono/vite-dev-server@0.12.1
+ vite@5.3.1
+ wrangler@3.60.3
+ hono@4.4.6

97 packages installed [1403.00ms]

 
bun run dev して、http://localhost:5173/ へアクセスすると、 Hello! が表示されました。

 

concurrently で Workers と Pages を同時に起動できるようにする

今後WorkersとPagesを連携して動作確認をしますが、それぞれのディレクトリで bun run dev するのは手間です。

そこで、 concurrently を使って、Workers と Pages を同時に起動できるようにします。

 
まずは、Workersである app-workerpackage.json で、起動コマンドを dev:worker へ修正します。

{
  // ...
  "scripts": {
    "dev:worker": "wrangler dev",
    // ...
  },
  //...
}

 
続いて、Pagesである app-pagepackage.json で、 name の追加と dev:page への修正を行います。

また、Bunのドキュメントにある通り、依存先の Worker を指定します。
なお、ドキュメントと異なり devDpendencies に設定しましたが、特に意図はないです。。ドキュメントに従うなら dependencies のほうが良いかもしれません。
Configuring a monorepo using workspaces | Bun Examples

{
  "name": "app-page",
  // ...
  "scripts": {
    "dev:page": "vite",
    "deploy": "bun run build && wrangler pages deploy"
    // ...
  },
  //...
  "devDependencies": {
    "app-worker": "workspace:*"
  }
  // ...
}

 
最後に、ルートにある package.json に、concurrently で Workers と Pages を同時に起動できるような script を追加します。

その際、Bunのfilterを使って、Workers と Pages をそれぞれ指定します。
Filter – Package manager | Bun Docs

{
  //...
  "scripts": {
    "dev": "concurrently \"bun run --filter=\"app-worker\" dev:worker\" \"bun run --filter=\"app-page\" dev:page\""
  },
  //...
}

 
準備ができたので動作確認します。

ルートディレクトリで bun run dev すると、WorkersとPagesの両方が起動しました。

それぞれのURLへアクセスしても、先ほどと同じように表示されます。

$ bun run dev

bun run dev
$ concurrently "bun run --filter="app-worker" dev:worker" "bun run --filter="app-page" dev:page"
app-worker dev:worker $ wrangler dev
[0] │ 
[0] │  ⛅️ wrangler 3.60.3
[0] │ -------------------
[0] │ 
[0] │ ⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787
[0] └─ Running...
app-page dev:page $ vite
[1] │ 
[1] │   VITE v5.3.1  ready in 592 ms
[1] │ 
[1] │   ➜  Local:   http://localhost:5173/
[1] │   ➜  Network: use --host to expose
[1] └─ Running...

 

Service Bindings RPC を使って、WorkersとPagesを連携する

WorkersとPagesができたので、次は Pages から Workers を呼び出す Service Bindings RPC を試してみます。

 

Workers で WorkerEntrypoint を使った実装へと差し替える

公式ドキュメントにある通り、 WorkerEntrypoint クラスを使った実装へと差し替えます。
The WorkerEntrypoint Class - Service bindings - RPC (WorkerEntrypoint) · Cloudflare Workers docs

今回は hello というメソッドを用意し、Pagesから呼び出せるようにします。

なお、今回はサンプルコードにあるように、 default export を使った実装にします。

また、 fetch メソッドも実装しておかないとエラーになるため、忘れずに実装しておきます。

import { WorkerEntrypoint } from 'cloudflare:workers'

export default class MyWorkerEntrypoint extends WorkerEntrypoint {
  async hello() {
    // biome-ignore lint: debug
    console.log('called worker!')
    return 'Hello my worker!'
  }

  fetch() {
    return new Response('Hello World!')
  }
}

 
以上で Workers の準備は完了です。

 

Pagesで Service Bindings RPC 用の設定を wrangler.toml に追加する

続いて Pages を実装します。

まずは、Service Bindings RPC 用の設定を wrangler.toml に追加します。
Service bindings - Configuration - Wrangler · Cloudflare Workers docs

Workersでは default export を使ったため、今回は bindingservice を定義します。

[[services]]
# ソースコードの中で参照する名前
# hono的には c.env.MY_WORKERのように参照する
binding = "MY_WORKER"

# serviceは app-workerのwrangler.tomlにある `name` と同じ名前にする必要がある
service = "app-worker"

 

Pages で Service Bindings RPC を使って、Workers を呼び出す

設定が終わったので、次は実装です。

Honoの app.get() の中で、 c.env.MY_WORKER.hell() のようにして、Service Bindings RPC を使います。

なお、 Service Bindings RPC を使うときには async を使うとのことです。
All calls are asynchronous | Remote-procedure call (RPC) · Cloudflare Workers docs

app.get('/', async (c) => {
  const r = await c.env.MY_WORKER.hello()

  return c.render(r)
})

 

usingを使ったRPCリソースの開放が必要か検討する

今回、Service Binding RPC を使っています。RPCにはLifecycleがあります。
Workers RPC — Lifecycle · Cloudflare Workers docs

そのため、以下のLifecycleのドキュメントや記事にある通り、リソースの開放が必要になるかもしれません。

 
今回の使い方を見てみると

app.get('/', async (c) => {
  const r = await c.env.MY_WORKER.hello()

  return c.render(r)
})

のように、Honoの app.get() 、Cloudflare 的には fetch handler の中でRPCを使っています。

Lifecycleの Automatic disposal and execution contexts に記載されている例に該当しそうなことから、今回は using キーワードを使わない実装で良さそうです。

End of event handler / execution context

When an event handler is “done”, any stubs created as part of the event are automatically disposed.

For example, consider a fetch() handler which handles incoming HTTP events. The handler may make outgoing RPCs as part of handling the event, and those may return stubs. When the final HTTP response is sent, the handler is “done”, and all stubs are immediately disposed.

 

More precisely, the event has an “execution context”, which begins when the handler is first invoked, and ends when the HTTP response is sent. The execution context may also end early if the client disconnects before receiving a response, or it can be extended past its normal end point by calling ctx.waitUntil().

 

For example, the Worker below does not make use of the using declaration, but stubs will be disposed of once the fetch() handler returns a response

 

https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle#automatic-disposal-and-execution-contexts

 
以上より、 Pages の実装も完了です。

 

動作確認

ルートディレクトリで bun run dev し、http://localhost:5173/ にアクセスします。

すると、 Hello my worker! というWorkerから取得した Hello my worker! が表示されました。

 

Wrangler で Workers と Pages をデプロイ

今まではローカルで Service Bindings RPC を動かしてきました。

次はCloudflareへデプロイし、動作を確認してみます。

 

Workers のデプロイ

まずは Workers からデプロイします。

app-worker ディレクトリの中に入り、以下を実行すると、デプロイが終わりました。

$ bun run deploy

 

Pages のデプロイ

Workers のデプロイが完了したことを確認後、Pages のデプロイを行います。

bun run deploy すると、初回デプロイのため、Pages の proeject が作成されます。

また、production ブランチについての質問もあります。ここまでの開発は feature ブランチ上で行ってきましたが、productionは main ブランチにします。

$ bun run deploy

$ bun run build && wrangler pages deploy
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 41 modules transformed.
dist/_worker.js  27.90 kB
✓ built in 249ms
The project you specified does not exist: "app-page". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'app-page' project.
🌎  Uploading... (1/1)

✨ Success! Uploaded 1 files (1.78 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://***.pages.dev

 
デプロイ後、 Cloudflare console を確認すると、 Preview な Environment にデプロイされました。

 
次に、今までの実装を main ブランチに取り込み & main ブランチへ切り替えしてから、デプロイします。

$ git branch
  feature/add_pages_and_workers
* main

$ bun run deploy
$ bun run build && wrangler pages deploy
$ vite build
vite v5.3.1 building SSR bundle for production...
✓ 41 modules transformed.
dist/_worker.js  27.90 kB
✓ built in 270ms
🌍  Uploading... (1/1)

✨ Success! Uploaded 0 files (1 already uploaded) (0.50 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://d10acd67.app-page-7vt.pages.dev/

 
Cloudflareのconsoleを確認すると、 Production へのデプロイが成功していました。

 
また、デプロイ先のURLを開くと、Workerから取得した値が表示されました。

 
ちなみに、初回デプロイ時によく見かけたのが、Pagesのデプロイで示されたURLを開くと Internal Server Error となることです。

Pagesのログを見ると "TypeError: e.env.MY_WORKER.hello is not a function" のようなエラーが出ていました。

{
  "outcome": "ok",
  "scriptName": "pages-worker--2956310-production",
  "diagnosticsChannelEvents": [],
  "exceptions": [],
  "logs": [
    {
      "message": [
        "TypeError: e.env.MY_WORKER.hello is not a function"
      ],
      "level": "error",
      "timestamp": 1718434302398
    }
  ],
}

 
ただ、手元では20分ほど待つと環境構築が終わり、Cloudflare上で Service Bindings RPC で Worker からデータを取得・表示できるようになりました。

また、一度解消してしまえば、再発はしないようでした。

 

Githubと連携して、Pagesをデプロイする

Cloudflare Pagesは、Wrangler以外にも、Githubと連携してデプロイすることができます。
Git integration · Cloudflare Pages docs

そこで、Githubと連携したときのデプロイ方法を確認してみます。

 

Pagesの wrangler.toml に設定を追加

今回のGithubからのデプロイでも Bun を使います。

ただ、今のままではデプロイ時の Bun のバージョンはデフォルトのままになってしまいます(2024/6/15時点では、1.0.1)。
Language support and tools · Cloudflare Pages docs

デプロイログにはこんな感じで出力されており、 Bun 1.0.1 が使われていることが分かります。

2024-06-15T02:56:03.647712Z  Detected the following tools from environment: bun@1.0.1, nodejs@18.17.1
2024-06-15T02:56:03.648272Z Installing project dependencies: bun install --frozen-lockfile
2024-06-15T02:56:03.913028Z bun install v1.0.1 (31aec4eb)

 
そこで、Pages の wrangler.toml環境変数を設定し、指定した Bun のバージョンを使ってデプロイするようにします。

[vars]
BUN_VERSION = "1.1.12"

 

Cloudflare と Github を連携する

続いて、Cloudflareのドキュメントに従い、CloudflareからGithubへアクセスできるようにします。
Git integration guide · Cloudflare Pages docs

今回は、必要最低限のリポジトリのみアクセス可能な設定としました。

 

Cloudflare dashboard からデプロイ

最後に、上記のGit integration guideに従って Cloudflare dashboard を操作し、 Deploy a site from your account まで移動します。

その後は、以下の内容を選択・入力し、 Save and Deploy ボタンをクリックします。

項目
Github account thinkAmi-sandbox
Select a repository cf_service_binding_rpc-example
Project name cf-service-binding-rpc-example (デフォルトのまま)
Production branch main
Build Settings - Framework preset None
Build Settings - Build command bun install && bun run build
Build Settings - Build output directory dist
Build Settings - Root directory - Path packages/app-page
Build Settings - Environment variables (なし)

 
するとビルドが進行し、デプロイまで正常に完了しました。

Connect to project ボタンを押した後、数分待ち、アプリが展開されるのを待ちます。
(展開されるまでは「このサイトにアクセスできません」な旨が表示されます)

 

動作確認

アプリの展開が完了後、 Visit site をクリックして Pages で作ったサイトへ遷移すると、 Hello my worker! が表示されました。

 

セキュリティ面から Named entrypoint を使うように修正する

ここまで、RPCで使う Worker のentrypointは、default export したクラスでした。

ただ、Cloudflareのブログによると、default export した entrypoint ではなく、 named export して named entrypoint を使ったほうがセキュリティが向上するようです。

n the past, service bindings would bind to the "default" entrypoint, declared with export default {. But, the default entrypoint is also typically exposed to the Internet, e.g. automatically mapped to a hostname under workers.dev (unless you explicitly turn that off). It can be tricky to safely assume that requests arriving at this entrypoint are in any way trusted.

 

With named entrypoints, this all changes. A named entrypoint is only accessible to Workers which have explicitly declared a binding to it. By default, only Workers on your own account can declare such bindings. Moreover, the binding must be declared at deploy time; a Worker cannot create new service bindings at runtime.

 
We've added JavaScript-native RPC to Cloudflare Workers

 
そこで、Named entrypoint を使うよう、Workers と Pages を修正します。

 

Workers の修正

WorkerEntrypoint を継承したクラス MyWorkerEntrypoint は named export するように修正します。

ただ、Workers として動かすためには、 fetch handlerは default export に含む必要があります。

そこで、fetchMyWorkerEntrypoint のメソッドではなく default export の方へと移動しました。

なお、結果を分かりやすくするよう、hello メソッドの戻り値も Hello my named export worker! へと変更しています。

import { WorkerEntrypoint } from 'cloudflare:workers'

export class MyWorkerEntrypoint extends WorkerEntrypoint {
  async hello() {
    // biome-ignore lint: debug
    console.log('called worker!')
    return 'Hello my named export worker!'
  }
}

export default {
  async fetch() {
    return new Response('Hello Worker World!')
  },
}

 

Pages の修正

Pages では wrangler.tomlentrypoint を追加するよう修正します。

[[services]]
binding = "MY_WORKER"
service = "app-worker"

# Workersでexportされたクラス名を設定
entrypoint = "MyWorkerEntrypoint"  # 追加

 

動作確認

修正が終わったので動作確認します。

まずはローカルでの確認です。

ルートディレクトリで bun run dev した後、 http://localhost:5173/ を表示すると、 Workers からの戻り値が差し替わっていました。

named export での動作になっているようです。

 
続いて、WorkersとPagesをデプロイし、Cloudflare上で動作を確認してみます。

こちらも Workers からの戻り値が差し替わり、named export での動作になっていました。

 
以上より、 Service Bindings RPC を使った Pages と Workers の連携を一通り確認できました。

 

今回の記事を作るにあたり悩んだこと

ここまで書いてきた内容以外にも、いくつか悩んだところがあったため、メモとして残しておきます。

 

Service Bindings RPC 関係

Workersで使う WorkerEntryPoint クラスにはハンドラの実装が必要

Workers には以下のようなハンドラがあります。
Handlers · Cloudflare Workers docs

そのため、RPC向けのメソッドしか使わないWorkersであっても、WorkerEntryPoint クラスには何かしらのハンドラの実装が必要なようです。

もしハンドラを実装しない場合、ローカルでは正常に動作します。

一方、Cloudflare上へデプロイするときにはエラーが発生します。

$ bun run deploy

...
Total Upload: 0.21 KiB / gzip: 0.18 KiB

✘ [ERROR] A request to the Cloudflare API (/accounts/***) failed.

  The uploaded script has no registered event handlers. [code: 10068]
  
  If you think this is a bug, please open an issue at:
  https://github.com/cloudflare/workers-sdk/issues/new/choose


error: script "deploy" exited with code 1

 
そのため、今回の記事の WorkerEntryPoint では、以下のコミュニティページでも書かれているように、お手軽な fetch ハンドラを実装しました。
No event handlers were registered. This script does nothing - Developers / Workers - Cloudflare Community

 

Cloudflare dashboard 上では、Pages の Service Bindings が使えないような表示になる

Cloudflare dashboard 上で Pages の Settings > Functions とたどると、Service bindings があります。

ただ、現時点ではスクリーンショットのように

This Worker no longer exists and can not be used. Please try using a different Worker.

という表示になっています。

 
最初この表示を見た時は「Service Binding RPC の設定がうまくいっていないのかな...」と悩みました。

ただ、

  • 実際には Service Bindings が使えていること
  • Cloudflareのdiscordで探したところ、2024/5/9時点で「表示バグであり、実際には使える」と書かれていたこと

から、自分の環境も表示バグなだけと判断しました。

 

Cloudflare dashboard + Github での Pages デプロイ関係

デプロイ時の Environment variables で Bun のバージョンを指定しても反映されない

デプロイ時の設定として Environment variables (advance) があります。

そのため、 wrangler.toml ではなく、ここに BUN_VERSION1.1.12 を指定しても良さそうです。

しかし、

  • Environment variables に BUN_VERSION を指定
  • wrangler.toml には BUN VERSION を指定しない

という場合は、デフォルトのBunのバージョンが利用されます。

デプロイログの途中には Build environment variables: (none found) と出ているの気になりますが。。

2024-06-15T02:57:57.922697Z  Cloning repository...
...
2024-06-15T02:58:00.466151Z Found wrangler.toml file. Reading build configuration...
2024-06-15T02:58:00.471401Z pages_build_output_dir: dist
2024-06-15T02:58:00.471716Z Build environment variables: (none found)
2024-06-15T02:58:00.577156Z Successfully read wrangler.toml file.
2024-06-15T02:58:00.737961Z Detected the following tools from environment: bun@1.0.1, nodejs@18.17.1
2024-06-15T02:58:00.738593Z Installing project dependencies: bun install --frozen-lockfile
2024-06-15T02:58:00.995652Z bun install v1.0.1 (31aec4eb)
...

 

Bun workspace を使っている場合、Build command には bun install も含めた方が良さそう

上記の説明では bun install && bun run build を指定していました。

ただ、元々は bun run build を使おうとしました。

しかし、 Bun の workspace を使っていることもあり、必要なパッケージが存在していない状態だとデプロイエラーになってしまいます。

例えば、以下の例では vite が見当たらないためにデプロイエラーになりました。

2024-06-15T02:15:35.790971Z  Cloning repository...
...
2024-06-15T02:15:39.536526Z Detected the following tools from environment: bun@1.1.12, nodejs@18.17.1
2024-06-15T02:15:39.537111Z Installing bun 1.1.12
2024-06-15T02:15:39.67107Z  Downloading Bun v1.1.12...
2024-06-15T02:15:40.784889Z Archive:  /tmp/asdf-bun.nxn8/bun.zip
2024-06-15T02:15:41.511631Z   inflating: /opt/buildhome/.asdf/downloads/bun/1.1.12/bun  
2024-06-15T02:15:41.575948Z Installing Bun v1.1.12...
2024-06-15T02:15:41.669111Z Bun v1.1.12 is installed successfully!
2024-06-15T02:15:42.151087Z Executing user command: bun run build
2024-06-15T02:15:42.408963Z $ vite build
2024-06-15T02:15:42.412208Z /usr/bin/bash: line 1: vite: command not found
2024-06-15T02:15:42.412421Z error: script "build" exited with code 127
2024-06-15T02:15:42.414061Z Failed: Error while executing user command. Exited with error code: 127
2024-06-15T02:15:42.423082Z Failed: build command exited with code: 1
2024-06-15T02:15:43.330076Z Failed: error occurred while running build command

 
そこで、 Build commandの先頭で bun install を実行することにより、 bun.lockb などを生成してパッケージ不足にならないようにしています。

bun install が必要なことには以下の記事を見て気づきました。記事ではpnpmを使っていますが、Bunでも同じことが起きました。
Cloudflare Pagesでpnpmを使ってデプロイする

 

Cloudflare dashboard + Github でデプロイできるのは、2024/6時点では Pages だけ

Cloudflare dashboard の表示を見ると、Pagesタブにしか Github 連携できる表示がありません。

 
また、monorepo のページも Pages のドキュメントにしかありません。
Monorepos · Cloudflare Pages docs

ただ、 Workers のディレクトリを指定したらどうなるのか気になりました。

 
そこで、 app-workerディレクトリを指定して Cloudflare dashボードからデプロイしてみました。

すると、途中で色々Skipはされたものの、Workerのソースコードのデプロイ自体は成功しました。

2024-06-15T02:05:07.299536Z  Cloning repository...
...
2024-06-15T02:05:09.881621Z Found wrangler.toml file. Reading build configuration...
2024-06-15T02:05:09.980638Z A wrangler.toml file was found but it does not appear to be valid. Did you mean to use wrangler.toml to configure Pages? If so, then make sure the file is valid and contains the `pages_build_output_dir` property. Skipping file and continuing.
2024-06-15T02:05:10.060341Z No build command specified. Skipping build step.
2024-06-15T02:05:10.061276Z Note: No functions dir at /functions found. Skipping.
2024-06-15T02:05:10.061419Z Validating asset output directory
...
2024-06-15T02:05:19.424876Z Success: Your site was deployed!

 
しかし、デプロイ後のdashboardを見ると Pages として認識されているようでした。

 
当然、この状態では Workers として正常に動作していません。

ということで、2024/6時点では Workers は Wrangler でデプロイするのが良さそうでした。

 
ちなみに、Cloudflareのアナウンスによると、2024年の後半にはWorkersでもGithubからデプロイできるようになるかもしれません。

While today’s launch represents just a few of the many upcoming additions to converge Pages and Workers, we also wanted to share a few milestones that are on the horizon, planned later in 2024

...

Workers CI/CD. Later this year, we plan to bring the CI/CD system from Cloudflare Pages to Cloudflare Workers. Connect your repositories to Cloudflare and trigger builds for your Workers with every commit.

 

https://blog.cloudflare.com/pages-workers-integrations-monorepos-nextjs-wrangler

 

Cloudflare 関係

Pages のリクエストログは Deployment details の Real-time Logs で見れる

Pagesのビルドログの確認方法については、以下のページにありました。
Debugging Pages · Cloudflare Pages docs

一方、リクエストログはどこで見れるのか、ドキュメントを見つけられませんでした。ただ、機能としては存在しているので、忘れたとき用にメモを残します。

Pages の Deployments タブの 各デプロイの View details をクリックし、Deployment details へ移動します。

その中の Functions タブの下の方に Real-time Logs があり、そこでリクエストログを確認できます。

ただ、自動的には表示できないため、右側にある Begin log stream ボタンをクリックすることで、ログが流れてくるようになります。

 

wrangler.toml の Compatibility dates や flags について

Compatibility dates と Compatibility flags は、公式ドキュメントの以下のページに記載があります。
Compatibility dates · Cloudflare Workers docs

Change history もあり、どこでどんな変更が入ったのかわかるようになっています。

 

wrangler.toml のドキュメントのありか

Workers と Pages 、それぞれにドキュメントがあります。

 

Bun関係

現時点でのbun.lockb のバージョン管理について

Bunの場合lockfileは bun.lockb というバイナリファイルになります。
Lockfile – Package manager | Bun Docs

テキストベースの lockfile のissueもあるようですが、今はまだOpenしたままになっています。
Implement a text-based lockfile format · Issue #11863 · oven-sh/bun

JetBrainsの場合、YouTrackにもissueはありますが、こちらもOpenのままになっています。
Display human-readable info when opening bun lock file : WEB-67455

そのため、現時点では以下の記事のようにして管理するようです。
bun.lockbのVersion管理をGitでどうやる?問題

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/cf_service_binding_rpc-example

React + react-chartjs-2 + Chart.js を使って、デフォルトの Legend の代わりに HTML Legend を表示してみた

以前、React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみました。
React + react-chartjs-2 + Chart.js を使って、Pie chart を表示してみた - メモ的な思考的な

その時は凡例 (Legend)のカスタマイズとして「凡例をPie chartの右隣に表示する」をためしました。

 
そんな中、Pie chartの元データが多い場合、凡例にすべてを表示しきれないことがありました。

 
例えば、以下のPie chartの凡例は、ラベル1~ラベル8まで並んでいます。

凡例にはスクロールバーも表示されておらず、これを見ただけではラベル8までのデータに見えます。

 
しかし、実装を見てみると、本当はラベル10まで存在しています。

import { createLazyRoute } from '@tanstack/react-router'
import { ArcElement, Legend, Tooltip, Chart as chartJs } from 'chart.js'
import { Pie } from 'react-chartjs-2'

const Component = () => {
  chartJs.register(ArcElement, Tooltip, Legend)
  chartJs.overrides.pie.plugins.legend.position = 'right'

  const data = {
    labels: [
      'ラベル1',
      'ラベル2',
      'ラベル3',
      'ラベル4',
      'ラベル5',
      'ラベル6',
      'ラベル7',
      'ラベル8',
      'ラベル9',  // 凡例に表示されないラベル
      'ラベル10',  // 凡例に表示されないラベル
    ],
    datasets: [
      {
        label: '購入数',
        data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
        backgroundColor: ['mediumseagreen'],
      },
    ],
  }

  return (
    <div style={{ width: 300, height: 200 }}>
      <Pie data={data} />
    </div>
  )
}

export const Route = createLazyRoute(
  '/static_pie_chart_with_part_of_legend_missing',
)({
  component: Component,
})

 
このように、全ての凡例を見れないのは不便なことから、対応してみたときのメモを残します。

なお、今回は凡例の表示をメインにしていることもあり、CSSの調整は最低限にしています。

 
目次

 

環境

  • Windows11 WSL2
  • React 18.2.0
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • Hono 4.2.7
  • TanStack Router 1.30.1
  • TanStack Query 5.32.0

 
なお、今回はPie chartのデータソースについて

  • 固定値
  • バックエンドのAPIから取得した値

の2つを試してみることから、Honoなども使っています。

Honoなどの詳細については、以前の記事を参照ください。

 

デフォルトの Legend の代わりに HTML Legend を表示する

調査

まずは、Chart.jsのドキュメントにて、デフォルトの Legend をカスタマイズする方法を見てみます。
Legend | Chart.js

 
しかし、デフォルトで用意されている Legend では

  • スクロールバーを表示する
  • 凡例をすべて表示する

などの設定が見当たりませんでした。

 
次に、デフォルトの Legend の代わりとなる機能を探したところ、 HTML Legend がありました。plugin として実装すれば良さそうです。
HTML Legend | Chart.js

 
ただ、今回はReactでChart.jsを扱えるようにする react-chartjs-2 を使っています。そこで、 react-chartjs-2 で HTML Legend を使う方法を調べたところ、以下の stackoverflow がありました。

 
そのため、Pie chart コンポーネントplugin props に、 HTML Legend 向けの plugin を指定すれば良さそうです。

 

実装

最初に、Chart.js ドキュメントにある通り、レンダリングする前に呼ばれるプラグインafterUpdate イベントに plugin形式の HTML Legend を実装します。

 
なお、ひとまず動作すればよいとして、ここの実装では所々で any 型を使っています。

また、 afterUpdate が複数回呼ばれても良いようにするため、 document.getElementById('custom-ul') を使い element が存在していれば afterUpdate が実行されないようにしています。

const htmlLegendPlugin = {
  id: 'htmlLegend',
  afterUpdate(chart: any) {
    // Even if called multiple times, draw only once.
    const customUl = document.getElementById('custom-ul')
    if (customUl) return

    const items = chart.options.plugins.legend.labels.generateLabels(chart)
    const ul = document.createElement('ul')
    ul.id = 'custom-ul'

    items.forEach((item: any) => {
      const li = document.createElement('li')
      const boxSpan = document.createElement('span')
      boxSpan.style.background = item.fillStyle
      li.appendChild(boxSpan)
      li.appendChild(document.createTextNode(item.text))
      ul.appendChild(li)
    })

    const customLegend = document.getElementById('custom-legend')
    customLegend?.appendChild(ul)
  },
}

 
続いて、コンポーネントを実装します。

デフォルトの Legend を使ったときとの違いとしては

  • Pie コンポーネントplugin props に、上記で作成した plugin を指定
  • HTML Legend を表示するためのタグを用意し、idに custom-legend も定義
  • デフォルトの Legend を表示しないよう、 chartJs.overrides.pie.plugins.legend.display = false を設定

です。

const Component = () => {
  chartJs.register(ArcElement, Tooltip, Legend)
  chartJs.overrides.pie.plugins.legend.display = false

  const data = {
    labels: [
      'ラベル1',
      'ラベル2',
      'ラベル3',
      'ラベル4',
      'ラベル5',
      'ラベル6',
      'ラベル7',
      'ラベル8',
      'ラベル9',
      'ラベル10',
    ],
    datasets: [
      {
        label: '購入数',
        data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
        backgroundColor: ['mediumseagreen'],
      },
    ],
  }

  return (
    <div
      style={{
        width: 400,
        height: 200,
        // Use flexbox to arrange pie charts and legends side by side
        display: 'flex', // justifyContent: 'space-between', flexBasis: '50%'
      }}
    >
      <Pie data={data} plugins={[htmlLegendPlugin]} />
      <div
        id={'custom-legend'}
        style={{
          maxHeight: '100%',
          overflowY: 'auto',
          width: '200px',
          padding: '10px',
          boxSizing: 'border-box',
          border: '1px solid #ccc',
          backgroundColor: '#f9f9f9',
        }}
      />
    </div>
  )
}

 

動作確認

HTML Legend 版を表示してみたところ、凡例にスクロールバーが表示されました。

また、スクロールすることで、ラベル10まで表示できました。

 

凡例をクリックすることで、Pie chart 上の表示を ON / OFF できるようにする

デフォルトの Legend の場合、凡例をクリックすると、 Pie chart 上の表示を ON / OFF できます。

例えば以下の場合は、一番面積の広いラベル8を非表示にしています。

 
一方、先ほど作成した HTML Legend の場合は、凡例をクリックしても何も変化はありません。

そこで、Chart.jsの公式ドキュメントにある通り、items.forEach の中で onclick を追加し、表示の ON / OFF ができるようにします。
HTML Legend | Chart.js

 

実装

onclick の中では

をします。

items.forEach((item: LegendItem) => {
  const li = document.createElement('li')
  li.onclick = () => {
    if (item.index !== undefined) {
      chart.toggleDataVisibility(item.index)
      chart.update()
    }
  }

  // あとは同じ
}

 
また、 afterUpdate の直後にて、2回以上更新しないよう

const customUl = document.getElementById('custom-ul')
if (customUl) return

とした部分について、そのままだと以下のようなエラーになります。

chunk-XWX6J6U2.js?v=126d9035:2367 Uncaught TypeError: Cannot read properties of null (reading 'parentNode')
at _getParentNode (chunk-XWX6J6U2.js?v=126d9035:2367:24)
at DomPlatform.isAttached (chunk-XWX6J6U2.js?v=126d9035:6353:23)
at Chart.bindResponsiveEvents (chunk-XWX6J6U2.js?v=126d9035:9181:18)
at Chart.bindEvents (chunk-XWX6J6U2.js?v=126d9035:9126:12)
at Chart._checkEventBindings (chunk-XWX6J6U2.js?v=126d9035:8822:12)
at Chart.update (chunk-XWX6J6U2.js?v=126d9035:8771:10)
at li.onclick (static_pie_chart_with_html_legend.lazy.tsx:20:15)

 
そこで、こちらも Chart.js のドキュメントの実装に差し替えます。

const ul = getOrCreateLegendList()

// Remove old legend items
while (ul.firstChild) {
  ul.firstChild.remove()
}

 
他にも、 react-chartjs-2 のドキュメントを参考に、Chart.js まわりの型定義を any から適切な型へと変更しています。
How to use react-chartjs-2 with TypeScript? | react-chartjs-2

 
全体像は以下のコミットになります。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/2/commits/932253314049884a46da393fe7fd6b209127c0fe

   

動作確認

凡例のラベルをクリックすると、Pie chart から該当部分が非表示になりました。

 

バックエンドのAPIから取得して表示する場合の実装

ここまで Pie chart のデータソースは固定値でした。

そんな中、Webアプリケーションの場合など、バックエンドのAPIからデータソースを取得・表示する場合の実装はどうなるのかが気になりました。

 
実際にためしてみたところ、Pie コンポーネントの plugin props を使うことで、固定値の場合と同じ実装で実現できました。

同じ実装だったため、この記事では省略します。詳細は以下のコミットを見てください。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/2/commits/3278e5d5885784e3862fa94c0d0a96e6b6e9f9bb

 
上記のコミットを使って動かしてみた場合のスクリーンショットはこちらです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/react_chartjs_with_hono-example/pull/2

 
ちなみに、今回のサンプルコードでは、 Linter / Formatter として Biome を使いました。そのため、プルリクには Biome の設定も含まれています。

Bun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた

去年、Blueskyのアカウントを作りました。今のところブログの更新通知しかしてませんが。。。
https://bsky.app/profile/thinkami.bsky.social

 
そんな中、Blueskyに自分が投稿したものを取得したくなったことから、ためしてみたときのメモを残します。

 
目次

 

環境

  • Bun 1.1.6
  • TypeScript 5.4.5
  • @atproto/api 0.12.8

 

調査

BlueskyのAPIを今まで使ったことがなかったため、事前に調査しました。

 

APIを使うときのクレデンシャルについて

@atproto/api のREADMEを読んでいたところ、APIを使うときには identifierpassword が必要そうでした。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#session-management

identifier として使えそうなのは

  • ハンドルネーム
  • DID

のようでした。
自分のDIDを知る方法 - Bluesky

 
一方、パスワードは自分のログインパスワードではなく、アプリパスワードを使うのが良さそうでした。
AT Protocol APIでBlueskyに投稿する - くらげになりたい。

 

自分の投稿を取得するAPIについて

Blueskyのドキュメントを読むと、多くのAPIが用意されていました。
HTTP Reference | Bluesky

APIのうち、今回の目的に合いそうなのは app.bsky.feed.getAuthorFeed でした。
https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

また、 @atproto/api でも agent.getAuthorFeed(params, opts) として実装されていました。
https://github.com/bluesky-social/atproto/blob/main/packages/api/README.md#api-calls

 

実装

Bunで環境構築

まずはBunで環境構築をします。

$ bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (atproto_api-example):
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 
続いて、 @atproto/api をインストールします。

$ bun add @atproto/api
bun add v1.1.6 (e58d67b4)

 installed @atproto/api@0.12.8

 

.envファイルに秘匿情報を記載

Bunの場合、 .env ファイルに秘匿情報を記入しておけば、パッケージを追加することなく環境変数に値が設定されるようです。
Environment variables – Runtime | Bun Docs

 
そこで、今回の秘匿情報を .env ファイルに用意します。

IDENTIFIER=***
APP_PASSWORD=***

 

プログラムを書く

あとは getAuthorFeed を使うプログラムを書きます。

なお、今後別のAPIを使うかもしれないので、ファイル名を get_author_feed.ts へと変更しておきます。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const agent = new BskyAgent({
  service: 'https://bsky.social',
})


const main = async () => {
  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER
  })

  const records = feed.map(f => f.post.record as Record)
  const items = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(items)
}

main()

 

動作確認

Bunで実行してみると、自分の投稿が取得できました。

$ bun run get_author_feed.ts 
[
  {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entr,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
...
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }

 

ページング処理について

2024/05/08 追記

記事を公開後、 id:kkotyy さんよりページング処理に関するコメントをいただきました。

ページング処理について詳しくなかったことから、合わせて調べてみることにしました。

 

パラメータ limit について

パラメータ limit について、 app.bsky.feed.getAuthorFeed のドキュメントには

limit

integer

Possible values: >= 1 and <= 100

Default value: 50

https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed

とありました。

 
そこで、 limit =3 を設定し、挙動を確認してみます。

const {data: {feed}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3
})

const records = feed.map(f => f.post.record as Record)
const items = records.map(({text, createdAt}) => {
  return {
    text, createdAt
  }
})

console.log(items)

 
すると、最新から3件を取得する挙動へと変わりました。

$ bun run get_author_feed.ts
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 

パラメータ cursor について

パラメータ cursor について、 app.bsky.feed.getAuthorFeed のドキュメントではstring型の値を設定することしか記載されていませんでした。

続いて、 app.bsky.feed.getAuthorFeed の型定義を眺めてみたところ、リクエストパラメータの他、レスポンスパラメータにも cursor がありました。
https://github.com/bluesky-social/atproto/blob/%40atproto/api%400.12.8/lexicons/app/bsky/feed/getAuthorFeed.json#L39

 
そこで、レスポンスパラメータの cursor には何が含まれるのかを確認する get_author_feed_with_cursor.ts を作りました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  const {data: {feed, cursor}} = await agent.getAuthorFeed({
    actor: process.env.IDENTIFIER,
    limit: 3,
  })

  console.log(cursor)

  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  console.log(feeds)
}

main()

 
実行してみたところ、取得した一番最後の createdAt の値が設定されているようでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-06T14:43:40.051Z
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }
]

 
続いて、この取得したcursorの値をリクエストパラメータに含めた場合はどうなるのか気になりました。

そこで getAuthorFeed() の引数に cursor に先ほど console.log で出力された値を設定して、挙動を確認してみます。

const {data: {feed, cursor}} = await agent.getAuthorFeed({
  actor: process.env.IDENTIFIER,
  limit: 3,
  cursor: '2024-05-06T14:43:40.051Z'  // 追加
})

 
実行すると、先ほどとは別の3件が取得できました。

また、 cursor に指定した createdAt を持つ投稿は取得できませんでした。

$ bun run get_author_feed_with_cursor.ts 
2024-05-01T13:39:58.662Z
[
  {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com,
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }
]

 
これより、リクエストパラメータ cursor を使うことで、指定した値よりも前の投稿を取得できることが分かりました。

 

ページング処理を実装する

ここまでより、 limitcursor を組み合わせれば、今までの投稿数が少ない場合であっても「ページングして投稿を取得する」が実現できそうでした。

そこで、ページングするような get_author_feed_with_paging.ts を作ってみました。

import { BskyAgent } from "@atproto/api";
import type {Record} from "@atproto/api/dist/client/types/app/bsky/feed/post";

const getFeed = async (agent: BskyAgent, cursor: string)  => {
  const params = {
    actor: process.env.IDENTIFIER,
    limit: 5,
  }
  if (cursor) {
    params['cursor'] = cursor
  }

  const {data: {feed, cursor: nextCursor}} = await agent.getAuthorFeed(params)
  const records = feed.map(f => f.post.record as Record)
  const feeds = records.map(({text, createdAt}) => {
    return {
      text, createdAt
    }
  })

  return {feeds, nextCursor}
}

const main = async () => {
  const agent = new BskyAgent({
    service: 'https://bsky.social',
  })

  await agent.login({
    identifier: process.env.IDENTIFIER,
    password: process.env.APP_PASSWORD
  })

  let cursor: string | undefined = ''
  while (cursor != undefined) {
    const {feeds, nextCursor} = await getFeed(agent, cursor)
    console.log(`============================\n${cursor}\n============================`)
    console.log(feeds)

    cursor = nextCursor
  }
}

main()

 
実行してみたところ、ページング処理は成功し、一番最初の投稿まで取得できました。

$ bun run get_author_feed_with_paging.ts
============================

============================
[
  {
    text: "今回はこちらだけの投稿にしておこう",
    createdAt: "2024-05-07T13:31:29.133Z",
  }, {
    text: "はてなブログに投稿しました\nBun + TypeScript + @atproto/api で、Blueskyの自分の投稿を取得してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-07T13:31:01.142Z",
  }, {
    text: "はてなブログに投稿しました\nJetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った - メモ的な思考的な thinkami.hatenablog.com/entry,
    createdAt: "2024-05-06T14:43:40.051Z",
  }, {
    text: "はてなブログに投稿しました\nHono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-05T03:05:40.797Z",
  }, {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024,
    createdAt: "2024-05-02T11:40:29.210Z",
  }
]
============================
2024-05-02T11:40:29.21Z
============================
[
  {
    text: "はてなブログに投稿しました\nTypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2024/0...",
    createdAt: "2024-05-01T13:39:58.662Z",
  }, {
...
============================
2023-09-30T22:45:47.063Z
============================
[
  {
    text: "はてなブログに投稿しました\nTanstack QueryのuseQueryにて、refetchIntervalとstaleTimeを組み合わせたときの動作を確認してみた - メモ的な思考的な thinkami.hatenablog.com/entry/2023/1...",
    createdAt: "2023-09-30T22:44:44.988Z",
  }, {
    text: "Hello, world!",
    createdAt: "2023-09-30T21:33:33.670Z",
  }
]

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/atproto_api-example

ページング処理なしのプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/1

ページング処理のプルリクはこちら。
https://github.com/thinkAmi-sandbox/atproto_api-example/pull/2

JetBrains IDEで外部キーを表示できるよう、Cloudflare D1向けJDBC driver「d1-jdbc-driver」を修正するプルリクを作った

前回、自作のアプリを Cloudflare Pages + D1 に乗せてみました。
Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた - メモ的な思考的な

Cloudflare D1にあるテーブルの

で確認できます。

一方、アプリはWebStormなどのJetBrains IDEで開発しているため、JetBrains IDEでも Cloudflare に直接接続できると便利そうでした。

 
何かないか調べたところ、 d1-jdbc-driver がありました。READMEに従い設定してみたところ、JetBrains IDEのWebStormから接続できました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver

 
ただ、Known issueとして

Foreign keys are not currently shown in the introspection window, although they are still there and working as normal.

と、外部キーまわりのサポートがないようでした。

それがあると嬉しいと思いつつGithubのissueをながめていたところ、同じように外部キーまわりの機能を望んでいる方がいるようでした。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/issues/4

 
そこで、

  • 少なくとも、テーブルに foreign key だけでも表示できるといいなと思ったこと
  • JDBC driver の修正方法が気になること

から、修正してプルリクを出してみたところ、早速マージしていただきました。

 
ということで、今回の記事では、修正するために調べたことをメモとして残します。

 
目次

 

環境

 

調査

そもそもJavaJDBC driverに詳しくないので、基本的なところから調べ始めました。

 

KotlinでJDBC driverを書けるかについて

以前、JetBrains IDEプラグインを書いた時に Kotlin を使いました。
RubyMine 2023.3系から、rails routes を便利に扱える Railways プラグインが動かなくなったので、代替プラグイン Railroads を作りました - メモ的な思考的な

そのため、JavaではなくKotlinでJDBC driverを書けるといいなと思って調べたところ、「書ける」と書かれたstackoverflowの回答がありました。
Is it applicable to write a JDBC driver in Kotlin? - Stack Overflow

とはいえ、現在の d1-jdbc-driverJavaで実装されているため、Kotlinで書くのは別の機会にしました。

 

外部キーを表示するために必要な JDBC driver の修正について

現在の d1-jdbc-driver の実装では、いくつか SQLException("Not implemented: ***") となっているメソッドがありました。

このうち、外部キーに関係するメソッドを実装すれば、いい感じにJetBrains IDEで表示できるかもしれないと考えました。

 
次に、他のJDBC driverの実装を調べてみたところ、MicrosoftSQL Server 向け JDBC driver に詳しいドキュメントがありました。

ドキュメントを読むと

あたりで、 外部キー という記載がありました。

そのため、まずはこれら3つのメソッドを修正するところから始めることにしました。

 

SQLiteの外部キー情報を設定する方法について

修正対象のメソッドは分かりましたが、修正するためにはSQLiteの外部キー情報をどのように設定すればよいかが分かりません。

そこで、 d1-jdbc-driver の既存の実装を見たところ、主キーの情報を設定している箇所がありました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/blob/v1.1/src/main/java/org/isaacmcfadyen/D1DatabaseMetaData.java#L906-L962

 
これより、同じような感じで外部キー情報を設定し、

new D1ResultSet(ApiKey, AccountId, DatabaseUuid, columnNames, rows, columnSchema)

な感じで D1ResultSetインスタンスを生成すれば良さそうと分かりました。

 
また、設定する項目の値については、

を見比べる限り一致していました。

そのため、各メソッドでMicrosoftのドキュメントの項目に返すことで、何とかなりそうな気がしました。

 

UPDATE_RULEやDELETE_RULEの数値について

各メソッドでは、外部キーのActionを UPDATE_RULEDELETE_RULE として設定すれば良さそうと分かりました。

ただ、Microsoftのドキュメントによると、それらの値は数値であり、文字列ではありません。

Microsoftのドキュメントには数値も記載されていたものの、できれば数値のハードコーディングは避けたいです。

 
そこで調べてみたところ、Oracle Javaのドキュメントに記載がありました。

 
これより、既存の作りと同様

JSONObject ruleType = new JSONObject();
ruleType.put("NO ACTION", DatabaseMetaData.importedKeyNoAction);

とすれば、文字列を数値に変換できてハードコーディングしなくて済みそうとわかりました。

 

SQLiteの外部キー情報を取得する方法について

続いて、SQLiteの外部キー情報の取得方法を調べることにしました。

主キーでは queryDatabase("PRAGMA table_info(" + table + ")") のような感じでデータを取得していたため、同じような方法があるのかなと思って調べたところ、stackoverflowの回答がありました。
foreign keys - Output of the SQLite's foreign_key_list pragma - Stack Overflow

SQLitePRAGMAforeign_key_list() を使えば良さそうです。
https://www.sqlite.org/pragma.html#pragma_foreign_key_list

 

外部キー制約名の取得について

stackoverflowの回答 にあった foreign_key_list() で取得できる値には、外部キー制約名が含まれていませんでした。

どこで取得するんだろうと思って調べたところ、stackoverflowに回答がありました。
How to get the names of foreign key constraints in SQLite? - Stack Overflow

これより、「システムテーブルの SQL 列に CREATE TABLE したときのSQLが保存されているので、それをパース・取得する」くらいしか方法がないと分かりました。

 
ただ、

ということから、外部キー制約名を表示する優先度は高くなさそうと考えました。

 
そこで、今回はひとまず <table_name>_<id>_<seq> という形で対応することにしました。

 
ちなみに、外部キー名を編集するのではなく null や空文字を設定すると、 #FAKE みたいなprefixがついてしまいます。さすがに見栄えが良くないので編集することにしました。

 

JDBC driver の実装をデバッグする方法について

ここまでの調査で実装はできそうでした。

ただ、

  • 変数の中身の確認
  • うまく動いていないときの動作確認

をしたくなったことから、 JDBC driver の実装をデバッグする方法を探してみました。

すると以下の記事がありました。
JDBCドライバの作り方 #Java - Qiita

 
そこで、「テスト用の実行処理」を参考に、 main メソッドを持つ以下のようなクラスを作ってIntelliJ IDEAでデバッグ実行できるようにしました。

package org.isaacmcfadyen;

import java.sql.SQLException;

public class MyClass {
    public static void main(String[] args) throws SQLException {
        try (D1Connection con = new D1Connection("token", "account_id","database_id")) {
            var dbmd = con.getMetaData();
            var rs = dbmd.getCrossReference(null, null, null, null, null, "orders");
        }
    }
}

 
なお、 tokendatabase_id は、d1-jdbc-driver のREADMEに取得方法が記載されています。

一方、account_id は最初どこで見れるのか分かりませんでしたが、CloudflareのコンソールURLに含まれていると分かりました。

具体的には、 https://dash.cloudflare.com/<アカウントID>アカウント の値になります。

 

IntelliJ IDEAでビルドして jar ファイルを生成する方法について

今回、最終的には WebStormにJDBC driverの jar を設定したいのですが、そもそも jar を生成する方法がわかりません。

そこで方法を調べたところ、以下の記事がありました。

 
上記の記事を参考に、各種設定を行った後、Build ArtifactからReBuildを実行したところ、無事に jar ファイルができました。

 
なお、ビルドとリビルドの違いについては、IntelliJ IDEAのドキュメントに記載がありました。今回は規模が大きくないので、常時再ビルド(ReBuild)します。

https://pleiades.io/help/idea/working-with-artifacts.html#build-manually

 
ちなみに、WebStormで jar を追加のJDBC driverとして設定する方法は、 d1-jdbc-driver のREADMEに記載があります。
https://github.com/isaac-mcfadyen/d1-jdbc-driver

 

実装

以上で必要な調査が終わったので、あとは実装するだけです。

今回は

  • getImportedKeys
  • getExportedKeys
  • getCrossReference

を実装します。

 
なお、今回は最低限動くところがゴールなので、 getImportedKeysgetExportedKeys は同じ実装にしています。

もし JDBC driver の実装に詳しい方がいれば、上記2つのメソッドをより良く実装する方法を教えていただけるとありがたいです。

public class D1DatabaseMetaData extends D1Queryable implements DatabaseMetaData {
    // ...
    @Override
    public ResultSet getImportedKeys(String catalog, String schema, String table) throws SQLException {
        return getCrossReference(null, null, null, catalog, schema, table);
    }

    @Override
    public ResultSet getExportedKeys(String catalog, String schema, String table) throws SQLException {
        return getCrossReference(null, null, null, catalog, schema, table);
    }

    @Override
    public ResultSet getCrossReference(String parentCatalog, String parentSchema, String parentTable, String foreignCatalog, String foreignSchema, String foreignTable) throws SQLException {
        ArrayList<String> columnNames = new ArrayList<>();
        columnNames.add("PKTABLE_CAT");
        columnNames.add("PKTABLE_SCHEM");
        columnNames.add("PKTABLE_NAME");
        columnNames.add("PKCOLUMN_NAME");
        columnNames.add("FKTABLE_CAT");
        columnNames.add("FKTABLE_SCHEM");
        columnNames.add("FKTABLE_NAME");
        columnNames.add("FKCOLUMN_NAME");
        columnNames.add("KEY_SEQ");
        columnNames.add("UPDATE_RULE");
        columnNames.add("DELETE_RULE");
        columnNames.add("FK_NAME");
        columnNames.add("PK_NAME");

        JSONObject stringType = new JSONObject();
        stringType.put("type", "TEXT");
        JSONObject intType = new JSONObject();
        intType.put("type", "INTEGER");

        JSONArray columnSchema = new JSONArray();
        // PKTABLE_CAT
        columnSchema.put(stringType);
        // PKTABLE_SCHEM
        columnSchema.put(stringType);
        // PKTABLE_NAME
        columnSchema.put(stringType);
        // PKCOLUMN_NAME
        columnSchema.put(stringType);
        // FKTABLE_CAT
        columnSchema.put(stringType);
        // FKTABLE_SCHEM
        columnSchema.put(stringType);
        // FKTABLE_NAME
        columnSchema.put(stringType);
        // FKCOLUMN_NAME
        columnSchema.put(stringType);
        // KEY_SEQ
        columnSchema.put(intType);
        // UPDATE_RULE
        columnSchema.put(intType);
        // DELETE_RULE
        columnSchema.put(intType);
        // FK_NAME
        columnSchema.put(stringType);
        // PK_NAME
        columnSchema.put(stringType);

        JSONObject ruleType = new JSONObject();
        ruleType.put("NO ACTION", DatabaseMetaData.importedKeyNoAction);
        ruleType.put("CASCADE", DatabaseMetaData.importedKeyCascade);
        ruleType.put("SET NULL", DatabaseMetaData.importedKeySetNull);
        ruleType.put("SET DEFAULT", DatabaseMetaData.importedKeySetDefault);
        ruleType.put("RESTRICT", DatabaseMetaData.importedKeyRestrict);

        JSONObject results = queryDatabase("PRAGMA foreign_key_list(" + foreignTable + ")");
        JSONArray fkList = results.getJSONArray("results");
        ArrayList<ArrayList<Object>> rows = new ArrayList<>();

        for (int i = 0; i < fkList.length(); i++) {
            JSONObject fkItem = fkList.getJSONObject(i);

            ArrayList<Object> row = new ArrayList<>();
            row.add(null);
            row.add(null);
            row.add(fkItem.get("table"));
            row.add(fkItem.get("to"));
            row.add(null);
            row.add(null);
            row.add(foreignTable);
            row.add(fkItem.get("from"));
            row.add(fkItem.get("seq"));
            row.add(ruleType.get(fkItem.get("on_update").toString()));
            row.add(ruleType.get(fkItem.get("on_delete").toString()));

            // If null is set, #FAKE_<table>_<number> is set, so <foreignTable>_<id>_<seq> set
            row.add(foreignTable + "_" + fkItem.get("id").toString() + "_" + fkItem.get("seq").toString());
            row.add(null);

            rows.add(row);
        }

        return new D1ResultSet(ApiKey, AccountId, DatabaseUuid, columnNames, rows, columnSchema);
    }
// ...

 

動作確認

今回は

  • Wranglerでテスト用のD1を作る
  • Wranglerで各種外部キーを持つテーブルを作る
  • WebStormのDatabase toolsで表示する

という流れで動作確認をします。

 

Wranglerでテスト用のD1を作る

Wranglerのドキュメントに従い作成します。
https://developers.cloudflare.com/workers/wrangler/commands/#d1

$ npx wrangler login
$ npx wrangler d1 create d1-driver-test

 

Wranglerで各種外部キーを持つテーブルを作る

ON UPDATEON DELETE まわりをしっかり見たかったので、網羅するようなテーブル数と設定を行っています。

CREATE TABLE `shops` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `staffs` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `products` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text
);

CREATE TABLE `orders` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `name` text,
  `product_id` integer,
  `shop_id` integer,
  `staff_id` integer,
  FOREIGN KEY (`product_id`) REFERENCES `products`(`id`) ON UPDATE cascade ON DELETE set null,
  FOREIGN KEY (`shop_id`) REFERENCES `shops`(`id`) ON UPDATE set default ON DELETE restrict,  
  CONSTRAINT `fk__staff_who_ordered` FOREIGN KEY (`staff_id`) REFERENCES `staffs`(`id`) ON UPDATE no action ON DELETE no action
);

 
このSQLd1.sql みたいなファイルに保存し、

$ npx wrangler d1 execute d1-driver-test --remote --file=./d1.sql

とすることで、Cloudflare上のD1にテーブルができました。

 

WebStormのDatabase toolsで表示する

想定通りの表示となりました。

 

作ったプルリク

完成したのでプルリクを作りました。
https://github.com/isaac-mcfadyen/d1-jdbc-driver/pull/5

すると、早速マージしていただけました。ありがたい限りです。

 
なお、プルリクを作るためにforkしたリポジトリはこちらです。
https://github.com/thinkAmi/d1-jdbc-driver

 

その他資料

JDBC driver をゼロから作るときの資料

中身は詳しく見てないのですが、いつか役立つかもしれないので、リンクだけ置いておきます。

Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せてみた

少し前から、Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORM あたりをさわってきました。

 
今まではローカルのみでアプリ開発していたため、次はどこかにデプロイしたくなりました。

そこで、以前から気になっていた Cloudflare Pages と D1にアプリを乗せてみたところ、色々ハマったことがあったため、メモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • React 18.3.1
  • Chart.js 4.4.2
  • react-chartjs-2 5.2.0
  • Hono 4.3.1
  • TanStack Router 1.31.17
  • TanStack Query 5.34.1
  • Drizzle ORM 0.30.10
  • Drizzle Kit 0.20.17
  • Wrangler 3.47.0

 
なお、今回は Bun ではなく npm を使います。

ここまでの記事から問題なく動くような気もしますが、現在のCloudflareの公式ドキュメントでは Bun が控えめだったので、 npm にしておきました。

 

前置き

アプリの構成

今回のアプリは

  • Hono の上で React を動かす
  • Hono の中で Drizzle ORM を使い、D1からデータを取得する
  • フロントエンドのルーティングは TanStack Router を使う

という構成とします。このアプリを Cloudflare Pages にデプロイします。

 

Cloudflare Pages へのデプロイ方法

Cloudflareへアプリをデプロイする場合、

などがありました。

今回はCLIでさくっとデプロイしたいことから、 C3 もしくは Wrangler を使うことになりそうでした。

 
次に、C3 と Wrangler の違いを調べたところ、公式ドキュメントに

CLI

The Cloudflare Developer Platform ecosystem has two command-line interfaces (CLI):

  • C3: To create new projects.
  • Wrangler: To build and deploy your projects.

C3 & Wrangler · Build applications with Cloudflare Workers · Learning paths

との記載がありました。

 
「今回のように新しく作成アプリは C3 を使えばいいのかな」と思っていたところ、Honoの著者のyusukebeさんの記事にて

C3(Create Cloudflare CLI)コマンドでもHonoを選べますが今のところそれだとWorkersのテンプレートになるのでcreate honoで。

Honoの新しいCloudflare Pagesスターターについて

とありました。

 
2023年10月の記事なので、もしかしたら現在では C3 で良いのかもしれません。

ただ、Cloudflare に詳しくないこともあり、今回は以下の方法でアプリの作成とデプロイを行うことにしました。

  • npm create hono@latestcloudflare-pages テンプレートでアプリを作成
  • デプロイだけ Wrangler を使う

 
ちなみに、 C3 でHonoテンプレートを使った直後の状態は、以下のリポジトリに置いておきました。
https://github.com/thinkAmi-sandbox/hono_app_by_cloudflare_c3-example

Viteまわりの設定がないので、 npm create hono@latest の方が作りやすそうな印象です。

 

Cloudflare アカウントの作成

事前に作成しておきます。

また、2FAも設定しておきます。

 

アプリの作成

Hono + React + Chart.js + TanStack Router + TanStack Query なアプリを実装

まずは、以前の記事のアプリを作り、Cloudflare にデプロイするところまでやってみます。
Hono + React + Chart.js + TanStack Router + TanStack Query を使って、Hono製APIのレスポンスをPie chartとして表示してみた - メモ的な思考的な

 

Hono のセットアップ

今回は thinkami-react-hono-d1 というアプリ名でHonoをセットアップしました。

テンプレートは cloudflare-pages を選んでいます。

$ npm create hono@latest

Need to install the following packages:
create-hono@0.7.1
Ok to proceed? (y)
create-hono version 0.7.1
? Target directory thinkami-react-hono-d1
? Which template do you want to use? cloudflare-pages
✔ Cloning the template
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd thinkami-react-hono-d1

 

必要なライブラリをインストール

Reactまわりです。

$ npm i react react-dom
$ npm i -D @types/react @types/react-dom

 
Chart.jsまわりです。

$ npm i chart.js react-chartjs-2

 
TanStack Router まわりは以下の3つに関係するものを入れます。

なお、現在はバージョンが進んでいるので問題ないですが、バージョン 1.31.9 ではうまく動作しません。当初気づかず、時間を溶かしました。。。
A component suspended while responding to synchronous input in version 1.31.9 · Issue #1554 · TanStack/router

$ npm i @tanstack/react-router @tanstack/router-vite-plugin @tanstack/router-devtools @tanstack/router-cli

 
TanStack Query まわりです。

$ npm i @tanstack/react-query

 
以上で、ひとまずインストールは終わりです。

 

実装する上で悩んだこと

ここでの実装は、以前の記事の実装を移植しただけになります。

そのため、ここではコミットだけ置いておきます。
https://github.com/thinkAmi-sandbox/react_hono_with_cloudflare_pages_d1-example/commit/a90dc6a6002cac9b5bc300187485266dbc460d56

 
ただ、Cloudflareへデプロイするにあたり、自分が悩んだことをまとめておきます。

 

サーバ側のファイル名は src/index.tsx のままにする

今回、フロントエンドまわりのファイルは client ディレクトリの中に入れました。

そこで、バックエンドも server みたいなディレクトリに入れてもよいのかも...と考えて試してみたところ、

  • 522 エラーになる
  • 「Nothing is here yet. If the project exists, it may not be ready yet. Please check back later.」が出続ける

となってしまい、うまくいきませんでした。

そのため、サーバ側のファイルは src/index.tsx のままにしてあります。

 

TanStack Router で Code Splitting を使う場合は、ビルド後のファイルは assets へ出力しない

TanStack RouterでCode Splittingを使う場合、suffixを .lazy.tsx とすると容易に実現できます。
Code Splitting | TanStack Router React Docs

 
ただ、以前の記事の rollupOptions の設定だと、 .lazy.tsx のファイルはビルド後は assets ディレクトリへ出力されます。

そして、 assets ディレクトリに出力したままデプロイすると、動かした時に

Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html

というエラーが出てしまい動作しません。

 
そこで、今回は rollupOptions に chunkFileNames を設定し、 static ディレクトリへ出力するようにしました。

  if (mode === 'client') {
    return {
      build: {
        rollupOptions: {
          input: './src/client/main.tsx',
          output: {
            entryFileNames: 'static/client.js',
            chunkFileNames: 'static/[name]-[hash].js',  // これ
          }
        }
      },
// ...
    }
  } else {

 

defineConfigのサーバ側の plugin には pages も入れる

最初、うっかり pluginpages() を入れ忘れてしまい、

error during build:
RollupError: Could not resolve entry module "index.html".

というエラーを出し続けてしまいました。

そこで hono-spa-react リポジトリと差分を見ていたところ、この場所に pages() が不足していると気づきました。
https://github.com/yusukebe/hono-spa-react/commit/1f71afde1aeaef4d998247577cddb98761b21a54#diff-6a3b01ba97829c9566ef2d8dc466ffcffb4bdac08706d3d6319e42e0aa6890ddR23

 

TanStack Routerの Devtools は development のときだけ使うようにする

Cloudflareにデプロイした後も TanStack Router の Devtools が表示されていたので、おや?となりました。

そこで、TanStack Routerの公式ドキュメントを見て、 developement のときだけ動くようにしました。
Only importing and using Devtools in Development | Devtools | TanStack Router React Docs

 

TanStack Router の routeTree.gen.ts は、初回は CLI で作る

TanStack Router のルーティングが動いていないので調べたところ、 routeTree.gen.ts が無いことに気づきました。

そのため、Router CLItsr generate を実行し、ファイルを生成しました。
Router CLI | File-Based Routes | TanStack Router React Docs

 

ローカルでの動作確認

npm run dev したところ、Pie chartが表示されました。

 

WranglerでCloudflare Pagesへデプロイ

続いて、vite build --mode client && vite build && wrangler pages deploy dist (package.jsonでは npm run deploy) にて Cloudflare Pagesへデプロイします。

デプロイする際、TanStack Queryのワーニングが出ますが、今回はいったん置いておきます。

$ npm run deploy

> deploy
> vite build --mode client && vite build && wrangler pages deploy dist


♻️  Generating routes...
✅ Processed routes in 144ms
vite v5.2.11 building for client...
node_modules/@tanstack/react-query/build/modern/useQueries.js (1:0): Module level directives cause errors when bundled, "use client" in "node_modules/@tanstack/react-query/build/modern/useQueries.js" was ignored.
...
✓ 125 modules transformed.
dist/static/index.lazy-PW47UpKj.js    0.25 kB │ gzip:  0.21 kB
dist/static/link-CzmguEkQ.js          2.90 kB │ gzip:  1.48 kB
dist/static/chart.lazy-BYg6oVDi.js  174.51 kB │ gzip: 60.61 kB
dist/static/client.js               212.83 kB │ gzip: 67.83 kB
✓ built in 1.17s
vite v5.2.11 building SSR bundle for production...
✓ 24 modules transformed.
dist/_worker.js  22.46 kB
✓ built in 160ms

 
初回デプロイなこともあり、Cloudflare Pages のプロジェクト作成が必要になったので、質問に答えていきます。

今回 production branch name は main にしました。

The project you specified does not exist: "thinkami-react-hono-d1". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'thinkami-react-hono-d1' project.
▲ [WARNING] Warning: Your working directory is a git repo and has uncommitted changes

  To silence this warning, pass in --commit-dirty=true


🌏  Uploading... (5/5)

✨ Success! Uploaded 5 files (2.53 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Deployment complete! Take a peek over at https://f21b42fb.thinkami-react-hono-d1.pages.dev

 

Cloudflare Pages で動作確認

払い出されたURLにアクセスしたところ、ローカルと同じように表示されました。

こちらは / へのアクセスしたときの表示です。TanStack RouterのDevtoolsは非表示になっています。

 
こちらは /chart の表示です。こちらも良さそうです。

 

Cloudflare D1のデータを表示するようアプリを修正

以前作ったアプリがCloudflare Pages上で動くようになったため、次はCloudflare D1のデータを表示できるようアプリを修正します。

 

WranglerでCloudflare D1をセットアップ

D1のドキュメントに従い、 wrangler d1 create で作成します。
https://developers.cloudflare.com/workers/wrangler/commands/#create

今回のD1は my-d1 という名前にします。

$ wrangler d1 create my-d1

 ⛅️ wrangler 3.53.1
-------------------
✅ Successfully created DB 'my-d1' in region APAC
Created your new D1 database.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-d1"
database_id = "73b700ec-6f6e-40f3-b01f-bb3fd5864b7c"

 
次に、Wranglerの実行ログに出たD1の情報を wrangler.toml の末尾に追記します。

name = "thinkami-react-hono-d1"
pages_build_output_dir = "./dist"

# 以下を追記
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-d1"
database_id = "73b700ec-6f6e-40f3-b01f-bb3fd5864b7c"

 

Drizzle ORMをインストール

Drizzle ORM の公式ドキュメントにある Cloudflare D1 の項目に従い、Drizzle ORMとKitをインストールします。
Cloudflare D1 | Drizzle ORM - SQLite

$ npm i drizzle-orm
$ npm i -D drizzle-kit

 

drizzle.config.ts の作成

Cloudflare D1では、マイグレーションはCloudflare D1が提供しているものを使います。
Migrations · Cloudflare D1 docs

そこで、Drizzle Kitを使って migrations ディレクトリにマイグレーションファイルを出力できれば良さそうです。

 
ただ、そのディレクトリはDrizzle Kitのデフォルトとは異なることから、 drizzle.config.ts にて、マイグレーションファイルの出力先 out を指定しておきます。
Migrations folder | Drizzle ORM - Configuration

なお、今回もテーブルごとのスキーマファイルにすることから、 schema も設定しておきます。
Schema files paths | Drizzle ORM - Configuration

import type {Config} from "drizzle-kit"

export default {
  schema: "./src/schema/*",
  out: "./migrations",
} satisfies Config

 

Drizzle ORM向けのスキーマを作成

今回は Pie chart のデータソースを D1 に入れておきます。そこで、 colorsapples の2つのテーブルを用意します。

まずは src/schema/colors.ts です。IDと色名だけ保持します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";

export const colors = sqliteTable("colors", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
続いて、 src/schema/apples.ts です。こちらでは colors テーブルへの外部キー制約を付けています。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {colors} from "./colors";

export const apples = sqliteTable("apples", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  colorId: integer("color_id").references(() => colors.id),
  quantity: integer("quantity")
})

 

Drizzle Kitでマイグレーションファイルを自動生成

D1はSQLiteであることから、Drizzle Kitの drizzle-kit generate:sqlite を使ってD1向けのマイグレーションファイルを生成します。
Generate migrations | Drizzle ORM - List of commands

$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.10

No config path provided, using default 'drizzle.config.ts'
Reading config file 'path/to/drizzle.config.ts'
1 tables
publishers 2 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ migrations/0000_tidy_phantom_reporter.sql 🚀

 
migrations ディレクトリの中に1つのマイグレーションファイルができました。

$ tree migrations/
migrations/
├── 0000_tidy_phantom_reporter.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

1 directory, 3 files

 
0000_tidy_phantom_reporter.sql を開くと、2つのスキーマに対するSQLが記載されています。

CREATE TABLE `apples` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `color_id` integer,
    `quantity` integer,
    FOREIGN KEY (`color_id`) REFERENCES `colors`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `colors` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 

マイグレーションの実行

前回の記事では Drizzle の migrate() を使ってマイグレーションしました。

一方、Cloudflare D1で マイグレーションをするには、Wranglerapply コマンドを使います。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

 

マイグレーション適用状況を確認

マイグレーションする前に、適用状況を確認します。Wranglerでは wrangler d1 migrations list を使えば良さそうです。
migrations list | Commands - Wrangler · Cloudflare Workers docs

 
まずはローカル環境から確認します。ローカルに対して実行する場合は --local を付与します。

$ wrangler d1 migrations list my-d1 --local
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 
続いて本番のD1を確認します。

ドキュメントには書いてありませんが、本番のD1を確認するには --remote オプションが必要です。

$ wrangler d1 migrations list my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 

ローカル環境に対し、マイグレーションを実行

続いてマイグレーションmigrations apply で実行します。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

ひとまずローカルに対してのみ実行しますので、 --local オプションを付けておきます。

$ wrangler d1 migrations apply my-d1 --local
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
┌────────────────────────────────┬────────┐
│ name                           │ status │
├────────────────────────────────┼────────┤
│ 0000_tidy_phantom_reporter.sql │ ✅       │
└────────────────────────────────┴────────┘

 
マイグレーションが成功し、ローカルの .wrangler ディレクトリの中にSQLiteのファイルが生成されました。

 
生成された SQLite ファイルを確認すると、 colorsapples の2テーブルがありました。

 
念のため、本番環境のD1のマイグレーション適用状況も確認すると、まだ未適用でした。

$ wrangler d1 migrations list my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ Name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘

 
今回は、ローカル環境での動作確認完了後、本番環境をマイグレーションすることにします。

 

ローカル環境に対し、seedデータを投入

前回の記事では、Drizzle ORMにて、自分で seed 投入用プログラムを作りました。

一方、D1の場合はseed用の .sql ファイルを用意することで Wrangler から投入できるようです。
Build a Staff Directory Application · Cloudflare D1 docs

 
そこで、ルートに seeds ディレクトリを作り、その中に seed.sql を作成します。

INSERT INTO colors (name) VALUES ('firebrick'), ('gold'), ('pink'), ('mediumseagreen');

INSERT INTO apples (name, color_id, quantity) VALUES ('奥州ロマン', 1, 1), ('シナノゴールド', 2, 5), ('ピンクレディ', 3, 3), ('ブラムリー', 4, 2);

 
続いて、ローカル環境のSQLiteへseedデータを投入します。

$ wrangler d1 execute my-d1 --local --file=./seeds/seed.sql

 ⛅️ wrangler 3.53.1
-------------------
🌀 Mapping SQL input into an array of statements
🌀 Executing on local database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.

 
ローカル環境の各テーブルを見てみると、seedデータが存在していました。

colorsテーブル

 
applesテーブル

name 列のフォントが怪しいですが、今はこのままで進めます。

 

Honoアプリからローカル環境のD1へ接続できるよう vite.config.ts を修正

ローカル環境のD1へ接続する方法を調査

Cloudflare Pages では、Bindingsという機能を使ってPagesからD1へのアクセスが可能になるようです。
D1 databases | Bindings · Cloudflare Pages docs

また、ローカル開発時に Wrangler の開発サーバを使っていれば、いい感じにローカルのSQLiteへ接続できるようです。
Interact with your D1 databases locally | Bindings · Cloudflare Pages docs

 
ただ、今回のHonoアプリはViteの開発サーバで動作しています。

どうすればいいのだろうと思ったところ、yusukebeさんの記事に情報がありました。

「Bindingsはどうするの?」。実はローカルに限っては対応しています。というかPagesはwrangler --remoteできませんので、Pagesでできる範疇はすべてできます。

例えば、KVなんかこのようにvite.config.tsを編集すると使えるようになります。

(略)

D1もちょっと工夫すれば、ローカルのSQLiteを参照するようにして使えます。

デプロイ先で使いたければ、ダッシュボードからBindingsを有効にすればOKです。

 

Bindings | Honoの新しいCloudflare Pagesスターターについて

 
次に、ローカル環境のD1向けの設定を調べたところ、HonoXのissueに情報がありました。

Now, we can use the new API getPlatformProxy() in Wrangler. This will automatically read variables from wrangler.toml without having to write Bindings in vite.config.ts. The vite.config.ts can be written simply as follows:

 

https://github.com/honojs/honox/issues/39#issuecomment-1955716487

 
issueのコメントからリンクされていたCloudflare Workersのドキュメントを読むと、 getPlatformProxy() では D1 database bindings もサポートされていました。
getPlatformProxy | API · Cloudflare Workers docs

 
以上より、ローカル環境のD1へ接続するには getPlatformProxy() を使えば良さそうと分かりました。

 

vite.config.ts を修正

HonoXのissueのコメントに従い、 vite.config.ts のうち、サーバ側の設定の devServer に対し、

  • env
  • plugins

の設定を追加しました。

import pages from '@hono/vite-cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import {defineConfig} from 'vite'
import {TanStackRouterVite} from "@tanstack/router-vite-plugin"
import {getPlatformProxy} from "wrangler";

export default defineConfig(async ({ mode }) => {
  const { env, dispose } = await getPlatformProxy()

  if (mode === 'client') {
    // ...
  } else {
    return {
      // ...
      plugins: [
        // ...
        // env と plugins を設定
        devServer({
          entry: 'src/index.tsx',
          env: env,
          plugins: [
            {
              onServerClose: dispose
            }
          ]
        })
      ]
    }
  }
})

 

HonoでD1の中身を返すよう修正

今まではAPIのエンドポイント /api/apples にてハードコーディングした値を返しています。

そこで、ハードコーディングではなくD1の値を返すよう修正します。

 
今回は、 apples テーブルと colors テーブルを INNER JOINした結果を返すよう修正します。

ただ、現在のD1 + Drizzle ORMの組み合わせだとバグがあることからメモを残しておきます。

 

D1 + Drizzle ORMを使う場合、Join系にバグがあるので回避

apples テーブルと colors テーブルをINNER JOINしようと、Drizzle ORMの innerJoin() を使って書きました。
Joins [SQL] | Drizzle ORM - Joins

const results = await db.select({
  name: apples.name,
  color: colors.name,
  quantity: apples.quantity,
}).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

console.log(results)

 
INNER JOINした結果を確認したところ、「name 列に色名が設定されている」など意図しないデータができていました。

[
  { name: 'firebrick', color: 1, quantity: undefined },
  { name: 'gold', color: 5, quantity: undefined },
  { name: 'pink', color: 3, quantity: undefined },
  { name: 'mediumseagreen', color: 2, quantity: undefined }
]

 
SQLがおかしいのかなと思い、all() の代わりに toSQL() メソッドで発行されるSQLを確認してみます。
Printing SQL query | Drizzle ORM - Goodies

  const p = await db.select({
    name: apples.name,
    color: colors.name,
    quantity: apples.quantity,
  }).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).toSQL()
  console.log(p)

 
すると、コンソールには以下が出力されました。SQLは正しそうです。

{
  sql: 'select "apples"."name", "colors"."name", "apples"."quantity" from "apples" inner join "colors" on "apples"."color_id" = "colors"."id"',
  params: []
}

 
次にGithubのissueを見たところ、すでにissueが上がっていました。約1年前のissueですが、まだOpenなようです。

 
この問題のワークアラウンドについては、上記issueや以下の記事に書いてありました。今回は SELECT する時に別名を付ける方針でいくことにしました。
Cloudflare D1とDrizzleの組み合わせてで困ったこと

 
Drizzle ORMで列に別名を付けるためには、 as<T>() が使えるのでためしてみます。
sql``.as() | Drizzle ORM - Magic sql`` operator

const results = await db.select({
  name: apples.name,
  color: sql`${colors.name}`.as('colorName'),  // 別名を付ける
  quantity: apples.quantity,
}).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

console.log(results)

 
ログを確認すると、今度は期待通りの値が取得できていました。

なお、文字に豆腐っぽいのが出ていますが、ここでは気にしないことにします。

[
  { name: '奥州ロマン', color: 'firebrick', quantity: 1 },
  { name: 'シナノゴールド', color: 'gold', quantity: 5 },
  { name: 'ピンクレディ', color: 'pink', quantity: 3 },
  { name: 'ブラムリー', color: 'mediumseagreen', quantity: 2 }
]

 

Hono APIのエンドポイントを修正

上記を踏まえ、APIエンドポイント /api/apples にて D1 よりデータを取得・変形して、呼び出し元へ返すよう修正します。

ちなみに、てきとうな実装になってますが、今回はサンプルコード的な実装なので気にしないことにします。

あと、テーブルの値をそのまま返してしまうとハードコーディングしているときと同じ結果になることに気づき、 quantity の値を +1 して返すようにしています。

const appleRoute = app.get('/api/apples', async (c) => {
  const db = drizzle(c.env.DB)

  const results = await db.select({
    name: apples.name,
    color: sql`${colors.name}`.as('colorName'),
    quantity: apples.quantity,
  }).from(apples).innerJoin(colors, eq(apples.colorId, colors.id)).all()

  console.log(results)

  const labels = results.map(r => r.name) ?? []
  // テーブルから値を取得していることを分かりやすくするため、テーブルの値 + 1 を設定
  const quantities = results.map(r => r.quantity ? r.quantity + 1 : 0) ?? []
  const colorNames = results.map(r => r.color) ?? []

  return c.json({
    labels: labels,
    datasets: [
      {
        label: '購入数',
        data: quantities,
        backgroundColor: colorNames,
        borderColor: colorNames,
        borderWidth: 1
      }
    ]
  })
})

 

ローカル環境での動作確認

実装が終わったので、ローカル環境で動作確認してみます。

Pie chartが表示され、かつ、テーブルの値が +1 されていました。良さそうです。

 

本番環境のD1をマイグレーション

本番環境のD1をマイグレーションするため、 d1 migrations apply--remote オプション付きで実行します。
migrations apply | Commands - Wrangler · Cloudflare Workers docs

$ wrangler d1 migrations apply my-d1 --remote
 ⛅️ wrangler 3.53.1
-------------------
Migrations to be applied:
┌────────────────────────────────┐
│ name                           │
├────────────────────────────────┤
│ 0000_tidy_phantom_reporter.sql │
└────────────────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Mapping SQL input into an array of statements
🌀 Parsing 3 statements
🌀 Executing on remote database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 3 commands in 0.7013ms
┌────────────────────────────────┬────────┐
│ name                           │ status │
├────────────────────────────────┼────────┤
│ 0000_tidy_phantom_reporter.sql │ ✅       │
└────────────────────────────────┴────────┘

 

デプロイして動作確認

あとは Cloudflare Pages へデプロイして動作確認するだけ...と思いきや、悩んだことがあったのでメモしておきます。

 

ビルドがハングするので、ビルドとデプロイを分離し、デプロイ

今まで同じように npm run deploy したところ、フロントエンドのビルドが終わったところでハングしたような挙動になりました。

$ npm run deploy

> deploy
> vite build --mode client && vite build && wrangler pages deploy dist

♻️  Generating routes...
✅ Processed routes in 163ms
...
dist/static/client.js               212.83 kB │ gzip: 67.83 kB
✓ built in 1.16s
(ここでずっと止まっている)

 
事例がないかを調べましたが見当たりませんでした。

そこで、表示を見る限り client.js のビルドは成功している感じだったため、ビルド単体で実行してみました。

しかし、それでもまた同じところでハングしてしまいました。

 
次に、フロントエンドとバックエンドのビルドを分けて実行してみました。

"scripts": {
  "build:frontend": "vite build --mode client",
  "build:backend": "vite build",
  "deploy": "wrangler pages deploy dist",
},

 
すると、フロントエンドはもちろん、バックエンドを各単体でビルドした場合でも

  • 表示はハングする
  • ビルドの成果物はきちんとできてそう

となりました。

なお、バックエンドの場合は、以下のあたりで止まります。

dist/_worker.js  76.74 kB
✓ built in 365ms
(ここでずっと止まっている)

 
そこで、ビルドの成果物さえできていればとりあえず問題ないだろうと考え、

  • (1) build:frontend でフロントエンドをビルド
    • ハングするが、ビルドの成果物ができたところでキャンセルする
  • (2) build:backend でバックエンドをビルド
    • ハングするが、ビルドの成果物ができたところでキャンセルする
  • (3) npm run deploy でデプロイ

の順に実行したところ、問題なく完了しました。

 

seed投入前の本番環境で動作確認

この時点の本番環境は

  • D1のマイグレーションは成功
    • seed は投入していないので、各テーブルの中身は空
  • デプロイも(いちおう)成功

という状態です。

 
そこで、動作確認したところ、chartのページはエラーは出ないものの Pie chart は表示されていませんでした。

seedは投入していないので、この表示は正しいという認識です。

 

本番環境のD1へ seed を投入

次に、 --remote オプションを付けて、本番環境のD1へ seed を投入します。
execute | Commands - Wrangler · Cloudflare Workers docs

$ wrangler d1 execute my-d1 --remote --file=./seeds/seed.sql
 ⛅️ wrangler 3.53.1
-------------------
🌀 Mapping SQL input into an array of statements
🌀 Parsing 2 statements
🌀 Executing on remote database my-d1 (73b700ec-6f6e-40f3-b01f-bb3fd5864b7c):
🌀 To execute on your local development database, remove the --remote flag from your wrangler command.
🚣 Executed 2 commands in 0.3165ms

 

seed投入後の本番環境で動作確認

ブラウザをリロードしたところ、Pie chartが表示されました。

 
これで、Hono + React + TanStack Router + TanStack Query + Chart.js + Drizzle ORMなアプリを、Cloudflare Pages と D1 に乗せることができました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/react_hono_with_cloudflare_pages_d1-example

Cloudflare Pagesのデプロイブランチを main にしたこともあり、今回はプルリクを作りませんでした。

TypeScript + Bun + SQLite な環境にて、SQLのDDLをDrizzle ORM で書いてみたり、初期データの投入(seed)をしてみた

前回の記事では Drizzle ORM のマイグレーション機能を中心に色々試していました。
TypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた - メモ的な思考的な

今回は SQLite に対して、SQLDDL を実行したり、初期データの投入(seed)をしてみたりします。

 
なお、Drizzle ORMでSQLDDLを実行する方法は、Githubリポジトリ「Drizzle ORM | SQLite」のREADMEにも詳しく書かれています。
https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/sqlite-core/README.md

また、制約やインデックスについては、Drizzle ORMの公式ドキュメントにも記載があります。
Drizzle ORM - Indexes & Constraints

 
目次

 

環境

  • Windows11 WSL2
  • TypeScript 5.4.5
  • Bun 1.1.6
  • SQLite
  • Drizzle ORM 0.30.9
  • Drizzle Kit 0.20.17

 

準備

前回の記事の続きから、実装を進めます。

ただ、前回は結果も記録したかったため、SQLiteファイルもコミットしてしまっていました。

 
そこで、今回は前回とは別のSQLiteファイル my_data.db を使用するよう、以下の2ファイルを変更しておきます。

migrate.ts

const sqlite = new Database("my_data.db")

 
drizzle.config.ts

export default {
  // ...
  dbCredentials: {
    url: "./my_data.db"
  }
} satisfies Config

 

主キーについて

ドキュメントによると、単一主キー・複合主キーとも設定できそうでした。
https://orm.drizzle.team/docs/indexes-constraints#primary-key

 

主キーなし

まずは主キーなしのテーブルを作ってみます。

スキーマ no_pks.ts を用意します。

import { text, sqliteTable } from 'drizzle-orm/sqlite-core'

export const noPks = sqliteTable("no_pks", {
  name: text("name"),
})

 
続いて drizzle-kit generate:sqlite を実行すると、以下のSQLを持つマイグレーションファイルが自動生成されました。

CREATE TABLE `no_pks` (
    `name` text
);

 
bun run migrate にてマイグレーションを実行すると、SQLiteにテーブルができました。

 

単一主キーあり

READMEより、Drizzle ORMでは primaryKey() を使うことで、単一主キーを設定できそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#column-types

 

サロゲートキーを主キーにする

今回はORMでよく見かけるサロゲートキーを主キーにしてみます。

なお、SQLiteのinteger型で自動インクリメント & 採番した番号は再利用しない目的で、 autoIncrement: true を追加しています。
SQLiteで主キーにAUTOINCREMENTを指定すると遅くなる #C# - Qiita

スキーマはこんな感じです。

import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'

export const writers = sqliteTable("writers", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
スキーマを元に生成されるマイグレーションファイルです。

CREATE TABLE `writers` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
マイグレーションすると、 writers テーブルができました。

 

SQLiteでは、後から主キーを追加できない

本題からはずれますが、SQLiteの公式ドキュメントに

4. ALTER TABLE ADD COLUMN

The ADD COLUMN syntax is used to add a new column to an existing table. The new column is always appended to the end of the list of existing columns. The column-def rule defines the characteristics of the new column. The new column may take any of the forms permissible in a CREATE TABLE statement, with the following restrictions:

  • The column may not have a PRIMARY KEY or UNIQUE constraint.
  • ...

https://www.sqlite.org/lang_altertable.html

とあったため、試してみます。

 
まずは主キーのない既存のスキーマに、主キーを追加します。

export const writers = sqliteTable("no_pks", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),  // 追加
  name: text("name"),
})

 
続いて generate:sqlite すると、以下のSQLを持つマイグレーションファイルが生成されました。

ALTER TABLE writers ADD `id` integer PRIMARY KEY NOT NULL;

 
最後に、 migrate したところエラーが表示されました。公式ドキュメント通りです。

$ bun run migrate.ts
...
DrizzleError: Failed to run the query 'ALTER TABLE no_pks ADD `id` integer PRIMARY KEY NOT NULL;'
...
SQLiteError: Cannot add a PRIMARY KEY column

 

複合主キーあり

READMEより、Drizzle ORMでは compositePk を使うことで、複合主キーを設定できそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#customizing-column-data-type

 
今回は、複合自然キーができるか試してみます。

複合自然キーの例として、今回は住所を使います。例えば 伊達市 の住所を特定するには都道府県も持っておく必要があります。

 
まず、 都道府県市区町村 を複合自然キーとするスキーマ addresses.ts を用意します。

ちなみに、複合主キーを作りたい場合、READMEには compositePk: primaryKey(pkExample.id, pkExample.name) と書かれています。

ただ、v0.30.9ではその書き方はdeprecatedです。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/primary-keys.ts#L11

その代わり、 compositePk: primaryKey({columns: [address.prefecture, address.municipality]}) という書き方にすればよいようです。

 
スキーマの全体は以下です。

import {primaryKey, sqliteTable, text} from 'drizzle-orm/sqlite-core'

export const addresses = sqliteTable("addresses", {
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (address) => ({
  compositePk: primaryKey({columns: [address.prefecture, address.municipality]})
}))

 
スキーマから生成されたマイグレーションファイルはこちら。

CREATE TABLE `addresses` (
    `prefecture` text,
    `name` text,
    PRIMARY KEY(`name`, `prefecture`)
);

 
マイグレーションすると、複合主キーを持ったテーブル addresses ができました。

 

制約

次に色々な制約をためしてみます。

 
なお、SQLiteでは ALTER TABLE が限定的なサポートになっています。
SQL Features That SQLite Does Not Implement

そこで、制約を定義する場合に Drizzle ORM がどこまでサポートしてくれるのかも確認していきます。

 

NOT NULL制約

ドキュメントより、Drizzle ORMでは notNull() を使うことで NOT NULL制約を追加できそうでした。

既存のテーブルに、NOT NULL 制約ありの列を追加

スキーマでは、 notNull() メソッドを使って NOT NULL 制約を追加します。

export const writers = sqliteTable("writers", {
  pseudonym: text("pseudonym").notNull()  // 追加
})

 
マイグレーションファイルを生成します。

ALTER TABLE writers ADD `pseudonym` text NOT NULL;

 
マイグレーションすると、 writers テーブルに NOT NULL 制約付きで pseudonym が追加されました。

WebStormのDatabase Toolsで見るとこんな感じです。

 

既存のテーブル・列への NOT NULL 制約追加は自動生成不可

SQLiteの場合、ALTER TABLE 機能はあるものの、ALTER COLUMN はないようです。

 
とはいえ、Drizzle ORM ではどうなるかを試してみます。

今回は、既存のテーブル writers の列 name に NOT NULL 制約を追加してみます。

まずはスキーマを修正します。

export const writers = sqliteTable("writers", {
  name: text("name").notNull()  // NOT NULL制約を追加
})

 
マイグレーションファイルを生成します。

$ drizzle-kit generate:sqlite                                                                                                                                                                           

drizzle-kit: v0.20.17
drizzle-orm: v0.30.9
...
[✓] Your SQL migration file ➜ drizzle/0006_flimsy_proteus.sql 🚀

 
生成されたマイグレーションファイルを見てみると、コメントが記載されていました。もし実装したい場合は、自分で実装する必要があるようです。

/*
 SQLite does not support "Set not null to column" out of the box, we do not generate automatic migration for that, so it has to be done manually
 Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php
                  https://www.sqlite.org/lang_altertable.html
                  https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3

 Due to that we don't generate migration automatically and it has to be done manually
*/

 

UNIQUE制約

ドキュメントより、Drizzle ORMでは uniqueIndex() を使うことで UNIQUE制約を追加できそうでした。

 
今回は、列 pseudonym に UNIQUE 制約を追加してみます。

また、UNIQUE制約を付与する際のインデックス名は unique_pseudonym とします。

export const writers = sqliteTable("writers", {
  // ...
  pseudonym: text("pseudonym").notNull()  // この列に対し、UNIQUE制約を追加
}, (writer) => ({
  uniqueIdx: uniqueIndex("unique_pseudonym").on(writer.pseudonym)
}))

 
マイグレーションファイルを生成したところ、以下のSQLができました。SQLite的にはIndexの生成のようです。

CREATE UNIQUE INDEX `unique_pseudonym` ON `writers` (`pseudonym`);

 
マイグレーションすると、UNIQUE 制約の index ができていました。

 

v0.30.9 時点では、CHECK制約は未実装

ドキュメントでは、CHECK制約は未実装と書かれていました。
https://orm.drizzle.team/docs/indexes-constraints#check

次にGithubを見たところ、CHECK 制約に関するissueがOpenのままでした。
[FEATURE]: Add check support in drizzle-kit · Issue #880 · drizzle-team/drizzle-orm

ちなみに、上記 issue には現時点での回避策も記載されていました。 default() の中で生SQLを書くと対応できるケースもあるようです。
https://github.com/drizzle-team/drizzle-orm/issues/880#issuecomment-1814869720

 

DEFAULT制約

READMEより、Drizzle ORMでは default() を使うことで DEFAULT 制約の追加ができそうでした。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/README.md#column-types

ただ、SQLiteには ALTER COLUMN がないことから、今回は列を追加する時にDEFAULT制約を設定してみます。

 
まずは、デフォルト値として空文字を設定する comment 列をスキーマへ追加します。

export const writers = sqliteTable("writers", {
  comment: text("comment").default(""),  // 列を追加

 
マイグレーションファイルを生成したところ、以下のSQLが生成されました。

ALTER TABLE writers ADD `comment` text DEFAULT '';

 
マイグレーションすると、DEFAULT値を持つ列 comment が追加されていました。

 

外部キー制約

ドキュメントによると、Drizzle ORMでは単一・複合・自己参照の各外部キーが設定できるようです。

 

単一外部キー

単一外部キーを試すために、2つのスキーマを作成します。

まずは、参照先の publishers を作成します。

export const publishers = sqliteTable("publishers", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 
続いて外部キー制約のある books を作成します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {publishers} from "./publishers.ts";

export const books = sqliteTable("books", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  publisherId: integer("publisher_id").references(() => publishers.id) 
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

CREATE TABLE `books` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `publisher_id` integer,
    FOREIGN KEY (`publisher_id`) REFERENCES `publishers`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `publishers` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
マイグレーションすると、2つのテーブルができていました。

publishers テーブルはこちら。

 
books テーブルには外部キーが設定されています。

 

複合外部キー

SQLiteやDrizzle ORM では複合外部キーもサポートされているので、試してみます。

 

新規テーブル作成時に複合外部キーを設定

新規テーブル shops を追加し、 addresses テーブルへの複合外部キーを設定してみます。

なお、ソースコードによると foreignKey() はコールバックを使うと deprecated のようなので、オブジェクトを渡す形にします。
https://github.com/drizzle-team/drizzle-orm/blob/0.30.9/drizzle-orm/src/sqlite-core/foreign-keys.ts#L101

export const shops = sqliteTable("shops", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (table) => (
  {
    fk: foreignKey({
      columns: [table.prefecture, table.municipality],
      foreignColumns: [addresses.prefecture, addresses.municipality],
      name: "address_names"
    })
  }
))

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

CREATE TABLE `shops` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `prefecture` text,
    `municipality` text,
    FOREIGN KEY (`prefecture`,`municipality`) REFERENCES `addresses`(`prefecture`,`municipality`) ON UPDATE no action ON DELETE no action
);

 
マイグレーションすると shops テーブルに外部複合キーが設定されていました。

 

既存のテーブルには外部複合キーを設定できない

続いて、既存の publishers テーブルから addresses テーブルへの複合外部キーを設定してみます。

pulishers テーブルに列と外部キーを追加するよう、スキーマを修正します。

export const publishers = sqliteTable("publishers", {
  // ...
  // 以下を追加
  prefecture: text("prefecture"),
  municipality: text("municipality"),
}, (table) => (
  {
    fk: foreignKey({
      columns: [table.prefecture, table.municipality],
      foreignColumns: [addresses.prefecture, addresses.municipality],
      name: "address_names"
    })
  }
))

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

SQLは出力されているものの、複合外部キーを設定するものとは異なっています。

ALTER TABLE publishers ADD `prefecture` text REFERENCES addresses(prefecture,municipality);--> statement-breakpoint
ALTER TABLE publishers ADD `municipality` text REFERENCES addresses(prefecture,municipality);--> statement-breakpoint
/*
 SQLite does not support "Creating foreign key on existing column" out of the box, we do not generate automatic migration for that, so it has to be done manually
 Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php
                  https://www.sqlite.org/lang_altertable.html

 Due to that we don't generate migration automatically and it has to be done manually
*/

 
マイグレーションすると、以下のエラーになりました。あとから複合外部キーは設定できなさそうです。

SQLiteError: foreign key on prefecture should reference only one column of table addresses

 

自己参照外部キー

Drizzle ORMのドキュメントを参考に、新規テーブル作成時に自己参照外部キーを設定してみます。
https://orm.drizzle.team/docs/indexes-constraints#foreign-key

既存テーブルへの追加については、他の制約同様設定できなさそうなので、今回は省略します。

 
まずは自己参照外部キーを設定する organizersスキーマを作成します。

parentId が自己参照外部キーです。

import {type AnySQLiteColumn, integer, sqliteTable, text} from "drizzle-orm/sqlite-core";

export const organizations = sqliteTable("organizations", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  parentId: integer("parent_id").references((): AnySQLiteColumn => organizations.id),
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

stackoverflowの回答と同じようなSQLになっています。
https://stackoverflow.com/questions/6516066/recursive-foreign-keys-in-sqlite

CREATE TABLE `organizations` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `parent_id` integer,
    FOREIGN KEY (`parent_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);

 
マイグレーションすると、自己参照外部キーが設定されている organizations テーブルができました。

 

外部キー制約のActionについて

Drizzle ORM のドキュメントによると、外部キー制約のActionも定義できるようです。
https://orm.drizzle.team/docs/rqb#foreign-key-actions

また、デフォルトのActionは

  • NO ACTION : This is the default action

とのことで、今まで生成された外部キーのSQLにあった ON UPDATE no action ON DELETE no action という定義と一致しています。

 
では実際に試してみます。(現実的にはあまりないケースですが) 今回は生成されるSQLを確認したいため、

  • 更新時は SET NULL
  • 削除時は CASCADE

と設定を分けてみます。

 
members テーブル新規作成するスキーマ members.ts を作成します。

import {integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import {organizations} from "./organizations.ts";

export const members = sqliteTable("members", {
  id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  organizerId: integer("organizer_id")
    .references(() => organizations.id, {
      onUpdate: 'set null',
      onDelete: 'cascade',
    })
})

 
生成されたマイグレーションファイルには、以下のSQLが記載されていました。

ON UPDATEON DELETEスキーマで指定した値が設定されています。

CREATE TABLE `members` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text,
    `organizer_id` integer,
    FOREIGN KEY (`organizer_id`) REFERENCES `organizations`(`id`) ON UPDATE set null ON DELETE cascade
);

 
マイグレーション後、Database Toolで確認すると、スキーマの設定が反映されていました。

 

列のデータ型について

公式ドキュメントにまとまっているため、今回は特にふれません。
Drizzle ORM - SQLite column types

 

初期データの投入 (Seed)

v0.30.9 時点では自分で実装する

Drizzle ORMやDrizzle Kitのドキュメントを探しましたが、seedについては記載が見当たりませんでした。

そのため、以下の記事や動画のように自分でseedするプログラムを書くことになりそうです。

 
実際にためしてみます。

ルートディレクトリに seed.ts を作り、上記記事を参考に実装します。

ちなみに、 insert する時に await がないとテーブルにデータが保存されません。

import {Database} from "bun:sqlite";
import {drizzle} from "drizzle-orm/bun-sqlite";
import {publishers} from "./src/schema/publishers.ts";

const main = async () => {
  const sqlite = new Database("my_data.db")
  const db = drizzle(sqlite)

  const data: (typeof publishers.$inferInsert)[] = [
    { name: "foo" },
    { name: "bar" },
  ]

  console.log("start ------------->")
  await db.insert(publishers).values(data)
  console.log("<----------------end")
}

main()

 
package.json に seed 用の scripts 設定を追加します。

なお、今回は Bun を使っているため、 TypeScript ファイルを直接実行できます。
bun run – Runtime | Bun Docs

"scripts": {
  "seed": "bun run seed.ts"
},

 
最後にコマンドを実行します。

# 実行
$ bun run seed

# ログ
$ bun run seed.ts
start ------------->
<----------------end

 
SQLiteファイルを開くと、seed.tsの内容が保存されていました。

ちなみに、何回か繰り返しているので、 id 列は 1 始まりではないです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example/pull/2

TypeScript + Bun + SQLite + Drizzle ORM な環境にて、Drizzle Kit の各コマンドを試してみた

TypeScript + Bun な環境にて、SQLiteを操作したいことがありました。

Bunにはネイティブの SQLite driver があることから、そのまま bun:sqlite を使うこともできそうでした。
SQLite – API | Bun Docs

ただ、日頃ORMでDBまわりを書いていることから、ORM的な何かを使いたくなりました。

 
BunのExamplesを見ていたところ、 Drizzle ORM が紹介されていました。

 
また、Drizzleでは

Drizzle Kit — is a CLI companion for automatic SQL migrations generation and rapid prototyping.

 

Drizzle ORM - Overview

と、 Drizzle Kit を使ってマイグレーションなどを行うことができそうでした。

 
他にも、Drizzleを使っている日本語の記事を読んだところ、自分が欲しいものとしてちょうど良さそうと感じました。

 
そこで、まずは Drizzle Kit の各コマンドをためしてみたときのメモを残します。

 
目次

 

環境

  • Windows11 WSL2
  • TypeScript 5.4.5
    • tsc -v より
  • Bun 1.1.6
  • DBは SQLite
    • bun:sqlite モジュールを使う
  • Drizzle ORM 0.30.9
  • Drizzle Kit 0.20.17
  • WebStorm 2024.1.1

 
なお、WebStorm 2024.1.1 では、まだ Bun を公式サポートしていないようです。以下のissueによると、デバッグまわりが厳しそうな印象です。

 
ただ、今回の記事ではデバッグは不要なので、WebStormで実装を進めることにします。

 

Bun 上で Drizzle Kit の各コマンドを実行するための準備

Bunのセットアップ

まだ何も Bun の環境ができていないことから、最初に Bun をセットアップします。

Bunの公式ドキュメントに従い、Bunをインストールします。
Installing | Installation | Bun Docs

今回は nodenv を使ってリポジトリごとに Node.js を使い分けている環境だったので、 npm でインストールします。

$ npm install -g bun

 
次に bun --version したところ、bun コマンドがないと言われてしまいました。

そこで、Bunの公式ドキュメントのHow to add to your PATH) に従い、追加で設定作業を行います。

まずは shell を確認します。

$ echo $SHELL
/bin/bash

 
bashだったので、 ~/.bashrc にの末尾に以下を追記します。

export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"

 
新しくターミナルを開いたところ、 bun コマンドを実行できました。

$ bun --version
1.1.6

 
Bunの設定が完了したため、 bun init を実行し、projectの設定をしておきます。
bun init – Templating | Bun Docs

$ bun init

bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit

package name (drizzle_with_bun):
entry point (index.ts):

Done! A package.json file was saved in the current directory.
 + index.ts
 + .gitignore
 + tsconfig.json (for editor auto-complete)
 + README.md

To get started, run:
  bun run index.ts

 

Drizzleのセットアップ

Bunの環境ができたので、次は Drizzle ORM や Drizzle Kit のセットアップを行います。

 

必要なパッケージのインストール

Drizzle の公式ドキュメントに従い、Bun SQLite を使うために必要なパッケージをインストールします。
Bun SQLite | Drizzle ORM - SQLite

まずは Drizzle ORMをインストールします。

$ bun add drizzle-orm
bun add v1.1.6 (e58d67b4)

 installed drizzle-orm@0.30.9

 1 package installed [734.00ms]

 
続いて Drizzle Kitを -D オプション付きでインストールします。

$ bun add -D drizzle-kit
bun add v1.1.6 (e58d67b4)

 installed drizzle-kit@0.20.17 with binaries:
  - drizzle-kit

 62 packages installed [3.02s]

 

drizzle.config.ts の作成

Drizzle ORMでは drizzle.config.ts ファイルにて設定を変更できます。
Drizzle ORM - Configuration

今回のDrizzle ORM の設定は

とするため、以下の内容で drizzle.config.ts を作成します。

import type { Config } from "drizzle-kit"

export default {
  schema: "./src/schema/*",
  out: "./drizzle",
} satisfies Config

 
以上で準備は終わりです。

 

drizzle-kit generate でマイグレーションファイルを作成

まずは drizzle-kit generateマイグレーションファイルを生成してみます。
Drizzle ORM - Migrations

ドキュメントによると、PythonDjangoのように、スキーマを元にマイグレーションSQLを自動で生成するようです。

実際にためしてみます。

 

スキーマファイルを作成

マイグレーションファイルの元となるスキーマファイルを作成します。

今回用意するスキーマ

  • テーブル名 authors
  • 列は以下の2つ
    • id

      • integer型
      • 主キーで、自動インクリメント
    • name

      • string型

とし、 src/schema/authors.ts へ定義します。

ちなみに、今回の定義では以下の点を考慮しています。

 
実際の定義はこちら。

import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
})

 

package.json に Drizzle Kit の generate コマンドを追加

コマンドは何度も使うことになりそうなので、 package.json へコマンドを追加しておきます。

"scripts": {
  "generate": "drizzle-kit generate:sqlite"
},

 

マイグレーションファイルを自動生成

続いて、 Drizzle Kit によるマイグレーションファイルの生成を行います。

Bunでは bun run により package.jsonscripts を実行できます。
Run a package.json script | bun run – Runtime | Bun Docs

そこで、先ほど追加した generate を実行してみます。

# 実行
$ bun run generate

# 以下はログ
$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file 'path/to/drizzle.config.ts'
1 tables
authors 2 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0000_yielding_the_spike.sql 🚀

 
生成されたファイル 0000_yielding_the_spike.sql を確認すると、以下のSQLが定義されていました。

CREATE TABLE `authors` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text
);

 
また、 meta ディレクトリの中にもファイルが生成されていました。

drizzle_with_bun/drizzle$ tree
.
├── 0000_yielding_the_spike.sql
└── meta
    ├── 0000_snapshot.json
    └── _journal.json

 

もう1つマイグレーションファイルを追加

次に、Drizzle Kit がスキーマの差分をうまく検知できるか、確認してみます。

先ほど作成したスキーマ authors.tsage 列を追加します。

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  age: integer("age"),  // 追加
})

 
再度 bun run generate したところ、新しくファイルが生成されました。

$ bun run generate

$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/home/thinkami/dev/projects/typescript/drizzle/drizzle_with_bun/drizzle.config.ts'
1 tables
authors 3 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0001_flawless_hitman.sql 🚀

 
生成された 0001_flawless_hitman.sql を確認すると、以下のSQLが生成されていました。差分が検知できているようです。

ALTER TABLE authors ADD `age` integer;

 
また、 metaディレクトリの中にも変化があり、

  • 0001_snapshot.json ファイルが追加
  • _journal.json ファイルの entries に要素が追加

となっていました。

 

migtate.ts を作成し、SQLiteマイグレーションファイルを適用

ここまででマイグレーションファイルを2つ作成しました。ただ、まだこれらのファイルはSQLiteへ適用していません。

そこで、これらマイグレーションファイルを適用していきます。

 
ただ、Drizzle ORMの公式ドキュメントには

Drizzle ORM is designed to be an opt-in solution at any point of your development flow. You can either run the generated migrations via Drizzle, or treat them as generic SQL migrations and run them with any other tool.

 
https://orm.drizzle.team/docs/migrations#quick-start

とありました。

Drizzle ORMやKitには、マイグレーションファイルを適用するコマンドが無いようです。

 
そこで、Bunのドキュメントに従い、 migrate.ts ファイルを作成する方針で対応します。
Use Drizzle ORM with Bun | Bun Examples

 

migrate.ts を作成

Bunのドキュメントにある migrate.ts をそのまま流用し、 migrate.ts を作成します。

import { migrate } from "drizzle-orm/bun-sqlite/migrator"

import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database } from "bun:sqlite"

const sqlite = new Database("sqlite.db")
const db = drizzle(sqlite)

// [Ignore] TS80007: await has no effect on the type of this expression.
await migrate(db, { migrationsFolder: "./drizzle" })

 

package.jsonマイグレーション適用コマンドを追加

migrate という名前で scripts にエントリを追加します。

"scripts": {
  "generate": "drizzle-kit generate:sqlite",
  "migrate": "bun run migrate.ts",
},

 

マイグレーションを適用

bun run migrate を実行すると、マイグレーションが適用され、 sqlite.db が追加されました。

WebStorm のプラグイン Database tools and SQL for WebStormsqlite.db を開くと、 authors テーブルができていました。

age 列も追加されていることから、2つのマイグレーションファイルが一気に適用されたようです。

 
また、SQLiteには __drizzle_migrations というテーブルもできていました。

公式ドキュメントによると

By default, all information about executed migrations will be stored in the database inside the __drizzle_migrations table, and for PostgreSQL, inside the drizzle schema. However, you can configure where to store those records.

 
https://orm.drizzle.team/docs/migrations#configurations

とのことです。

__drizzle_migrations テーブルの中身はこんな感じでした。

 

0.30.9 時点ではマイグレーションロールバック機能がない模様

migrateによるマイグレーション適用があるならば、その逆方向であるロールバックもあるのでは...と思い探してみたところ、Github の discussion がありました。
Migrations Rollback · drizzle-team/drizzle-orm · Discussion #1339

上記を読む限り、現時点ではロールバック機能がないようです。

 

drizzle-kit drop の挙動を確認

上記の通り、Drizzle にはマイグレーションロールバック機能はありません。

一方、Drizzle Kitのドキュメントには Drop migration のコマンドがありました。
Drop migration | Drizzle ORM - List of commands

このコマンドを使うと何が起こるのか気になったことから、試してみます。

 

Drop migrationの実行

drizzle-kit drop を実行してみます。

$ drizzle-kit drop
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Please select migration to drop:
  0000_yielding_the_spike
❯ 0001_flawless_hitman

# 選択後
[✓] 0001_flawless_hitman migration successfully dropped

 
 
実行後に git status を見たところ、以下が差分として出ていました。

マイグレーションファイルの削除の他に、metaディレクトリの中も変更しているようです。

deleted:    drizzle/0001_flawless_hitman.sql
deleted:    drizzle/meta/0001_snapshot.json
modified:   drizzle/meta/_journal.json

 
一方、SQLiteには変更を加えていないようで、

  • __drizzle_migrations テーブルのレコードに変化なし
  • authors テーブルにも age 列が残ったまま

という状態でした。

 
以上より、drizzle-kit drop は「不要なマイグレーションファイルを削除しつつ、自動生成した meta ディレクトリの中身を変更する」と理解しました。

 

Drop migration後に、差分を検知したり元に戻せるか確認

Drop migrationではマイグレーションファイルと実際のDBで差分が生まれてしまいました。

この差分を検知したり、元に戻せるかを確認してみます。

 

drizzle-kit up では差分を検知できない

ドキュメントには

We’re rapidly evolving Drizzle Kit APIs and from time to time there’s a need to upgrade underlying metadata structure.

drizzle-kit up:{dialect} is a utility command to keep all metadata up to date.

 

https://orm.drizzle.team/kit-docs/commands#maintain-stale-metadata

とあり、Drizzle Kit APIの変更によりメタデータが壊れないようにする目的のコマンドのようでした。そのため、今回の目的とは異なりそうです。

 
それでも、 drop 直後の状態(マイグレーションファイルとテーブルの状態が不一致)で、挙動を確認してみます。

$ drizzle-kit up:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Everything's fine 🐶🔥

Drizzle Kit のバージョンアップは行っていないこともあり、 Everything's fine でした。

マイグレーションファイルとテーブルの状態が不一致なことは関係ないようです。

 

drizzle-kit check でも差分を検知できない

drizzle-kit check について、公式ドキュメントには

drizzle-kit check:{dialect} is a very powerful tool for you to check consistency of your migrations.

That’s extremely useful when you have multiple people on the project, altering database schema on different branches.

Drizzle Kit will check for all collisions and inconsistencies.

 

https://orm.drizzle.team/kit-docs/commands#check

とありました。

そこで、ここまでの「マイグレーションファイルとテーブルの状態が不一致」という状態でためしてみます。

$ drizzle-kit check:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
Everything's fine 🐶🔥

Everything's fine と表示されました。Checkコマンド的には問題ないようです。

 

元に戻す方法を探す

ここまでで差分は検知できないようでした。

そこで、差分は検知できなくても元に戻す方法を探してみます。

 

【NG】再度 Generate migrations する

マイグレーションファイルを消してしまったのなら再度作り直せば良いのでは」ということで、再度マイグレーションファイルを生成してみます。

$ bun run generate
$ drizzle-kit generate:sqlite
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
1 tables
authors 3 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0001_real_raider.sql 🚀

 
再度マイグレーションファイルが生成されました。ファイル名は異なるものの、Drop migration した内容と一致しています。

ALTER TABLE authors ADD `age` integer;

 
マイグレーションファイルが生成できたので、 migrate してみます。

# 実行
$ bun run migrate

# 以降はログ
$ bun run migrate.ts
1 | import { entityKind } from "./entity.js";
2 | class DrizzleError extends Error {
3 |   static [entityKind] = "DrizzleError";
4 |   constructor({ message, cause }) {
5 |     super(message);
        ^
DrizzleError: Failed to run the query 'ALTER TABLE authors ADD `age` integer;'
      at new DrizzleError (/path/to/node_modules/drizzle-orm/errors.js:5:5)
      at run (/path/to/node_modules/drizzle-orm/sqlite-core/session.js:72:13)
      at migrate (/path/to/node_modules/drizzle-orm/sqlite-core/dialect.js:546:13)
      at migrate (/path/to/node_modules/drizzle-orm/bun-sqlite/migrator.js:4:3)
      at /path/to/migrate.ts:10:7

15 |   logger;
16 |   exec(query) {
17 |     this.client.exec(query);
18 |   }
19 |   prepareQuery(query, fields, executeMethod, isResponseInArrayMode, customResultMapper) {
20 |     const stmt = this.client.prepare(query.sql);
                      ^
SQLiteError: duplicate column name: age
 errno: 1

      at prepare (bun:sqlite:249:19)
      at prepareQuery (/path/to/node_modules/drizzle-orm/bun-sqlite/session.js:20:18)
      at run (/path/to/node_modules/drizzle-orm/sqlite-core/session.js:70:14)
      at migrate (/path/to/node_modules/drizzle-orm/sqlite-core/dialect.js:546:13)
      at migrate (/path/to/node_modules/drizzle-orm/bun-sqlite/migrator.js:4:3)
      at /path/to/migrate.ts:10:7
error: script "migrate" exited with code 1

 
エラーになってしまいました。 SQLiteError: duplicate column name: age とある通り、すでに age 列が存在しているために発生したようです。

 
この方法ではうまくいかないことがわかったので、 Drop migration しておきます。

$ drizzle-kit drop

...
[✓] 0001_real_raider migration successfully dropped

 

【NG】drizzle-kit introspect する

Drizzle Kit には、既存のDBからスキーマを生成する方法もあるようです。
https://orm.drizzle.team/kit-docs/commands#introspect--pull

そこで、 drizzle-kit introspect:sqlite を実行したところ、エラーになりました。

$ drizzle-kit introspect:sqlite

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'
 Invalid input  Either "turso", "libsql", "better-sqlite" are available options for "--driver"

 
公式ドキュメントを見たところ、bun:sqlite だけでは introspect コマンドは使えないようで、別途 driver の指定が必要そうでした。
https://orm.drizzle.team/kit-docs/config-reference#driver

 

introspect できるようセットアップする

設定ファイルの変更が必要そうなことから、 drizzle.config.ts

  • driver
  • dbCredentials

の設定を追加します。

export default {
  schema: "./src/schema/*",
  out: "./drizzle",

  // ここから下を追加
  driver: "better-sqlite",
  dbCredentials: {
    url: "./sqlite.db"
  }
} satisfies Config

 
また、公式ドキュメントによると、driverとして better-sqlite を使う場合は better-sqlite3 も必要そうなので追加でインストールします。
https://orm.drizzle.team/docs/get-started-sqlite#better-sqlite3

$ bun add better-sqlite3

 

introspect する

再度 introspect を実行してみたところ、ディレクトdrizzle の下にスキーマファイル schema.ts が生成されました。

中身を見ると、すべてのスキーマが1つのファイルに含まれていました。

import { sqliteTable, AnySQLiteColumn, numeric, text, integer } from "drizzle-orm/sqlite-core"
  import { sql } from "drizzle-orm"

export const drizzleMigrations = sqliteTable("__drizzle_migrations", {
    id: numeric("id").primaryKey(),
    hash: text("hash").notNull(),
    createdAt: numeric("created_at"),
});

export const authors = sqliteTable("authors", {
    id: integer("id").primaryKey({ autoIncrement: true }).notNull(),
    name: text("name"),
    age: integer("age"),
});

 
ただ、

から、この方法で元に戻すのは難しそうでした。

 

【OK】git reset --hard HEAD で戻す

仕方ないので

  • git reset --hard HEAD の実行
  • instrospect 実行時に生成されたファイルを削除

を行い、変更を元に戻しました。

 
見た目場は元に戻りましたが、この後の別の mirgate が成功するか気になりました。

そこで、authors.ts にスキーマの変更を追加し、ためしてみます。

export const authors = sqliteTable("authors", {
  id: integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }),
  name: text("name"),
  age: integer("age"),
  note: text("note"),  // 追加
})

 
マイグレーションファイルを生成します。

$ bun run generate

# ...
No config path provided, using default 'drizzle.config.ts'
Reading config file '/path/to/drizzle.config.ts'
1 tables
authors 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0002_slow_baron_zemo.sql 🚀

 
migrate します。

$ bun run migrate

 
SQLiteの構造を見ると、 note が追加されていました。問題なさそうです。

 

drizzle-kit studio により、ブラウザでSQLiteの中身を確認する

Drizzle Kit のドキュメントを見たところ、ブラウザでDBの中身を確認する Drizzle Studio 向けのコマンドがありました。

 
ためしてみたところ、エラーになりました。エラーメッセージから、

  • driver
  • dbCredentials

の設定が必要そうと分かりました。

$ drizzle-kit studio
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'
 Invalid input  You need to specify a "driver" param in you config. It will help drizzle to know how to query you database. You can read more about drizzle.config: https://orm.drizzle.team/kit-docs/config-reference
 Invalid input  You need to specify a "dbCredentials" param in you config. It will help drizzle to know how to query you database. You can read more about drizzle.config: https://orm.drizzle.team/kit-docs/config-reference

 
そこで、introspect 向けでセットアップしたときと同じ内容を drizzle.config.ts に追加します。

export default {
  schema: "./src/schema/*",
  out: "./drizzle",

  // ここから下を追加
  driver: "better-sqlite",
  dbCredentials: {
    url: "./sqlite.db"
  }
} satisfies Config

 
再度実行したところ、Drizzle Studio が起動しました。

$ drizzle-kit studio
drizzle-kit: v0.20.17
drizzle-orm: v0.30.9

No config path provided, using default path
Reading config file '/path/to/drizzle.config.ts'

[Warning] Drizzle Studio is currently in Beta. If you find anything that is not working as expected or should be improved, feel free to create an issue on GitHub: https://github.com/drizzle-team/drizzle-kit-mirror/issues/new or write to us on Discord: https://discord.gg/WcRKz2FFxN

Drizzle Studio is up and running on https://local.drizzle.studio

 
https://local.drizzle.studio にブラウザでアクセスしたところ、DBの中身が表示されました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/drizzle_with_bun-example/pull/1